Lot 23 : sonde de gabarit en Rust (avec LOKI_CHAT=rust)

Port de chat_tplprobe.go : la façade sonde le gabarit du moteur après ses
complétions (/health, /props, /apply-template), avec les verdicts
preserves_history, prefix_stable et renders_reasoning, le cache par
build/modèle/gabarit/forme et la relance bornée.

- l'écho du raisonnement suit le verdict de la sonde (echo_policy_for) ;
- NUDGE_IN_TOOL ne garde plus la conversation au Go : le rappel de budget
  va dans le dernier résultat d'outil quand le préfixe n'est pas stable ;
- le Go lit le verdict de la façade (/internal/facade/v1/chat/tpl) quand
  elle tient la conversation.

Parité : parity_tplprobe_test.go tire du Go un journal d'échanges rejoué
octet pour octet par tests/tplprobe.rs.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016tcGfJWefW5kUGoC2wL8cv
This commit is contained in:
Claude committed 2026-10-15 21:34:57 +00:00
1 parent 78766a28d9
commit 0f3a3d52c1
17 files changed
+5006 -22

No files matched your search

+7 -1
View File
@@ -494,7 +494,12 @@ impl ChatService {
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 echo = echo_policy_for(&cfg, &self.shared.echo_refusals, None);
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 {
@@ -1062,6 +1067,7 @@ impl ChatShared {
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()),
}
}
}
+36 -1
View File
@@ -39,7 +39,7 @@ use super::wire::{
ChatEndpoint, ChatPayloadOpts, EchoRefusals, EffortFallbacks, IMG_REF_SCHEME,
build_chat_payload, content_parts, part_image_url, wire_messages,
};
use crate::store::Store;
use crate::store::{BK_STATE, Store};
/// Taille au-delà de laquelle le cache d'images oublie les plus anciennes
/// (`imgCacheMax`).
@@ -284,6 +284,8 @@ pub struct ChatShared {
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>,
}
/// Ce que la compaction sait du tour.
@@ -490,6 +492,39 @@ impl TurnHost for GoTurnHost {
(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;
}
+3 -1
View File
@@ -17,7 +17,8 @@
//! - [`turn`] : la boucle d'un tour (complétions, outils, filets) ;
//! - [`host`] : le tour branché sur le moteur, les outils du Go et la base ;
//! - [`history`] : début et fin d'un tour côté historique ;
//! - [`generate`] : un tour de bout en bout (envoi, file, fin de tour).
//! - [`generate`] : un tour de bout en bout (envoi, file, fin de tour) ;
//! - [`tplprobe`] : sonde de gabarit (rendus de `/apply-template`).
pub mod compact;
pub mod conv;
@@ -31,6 +32,7 @@ pub mod nudges;
pub mod persist;
pub mod sessions;
pub mod stream;
pub mod tplprobe;
pub mod turn;
pub mod types;
pub mod verify;
+89
View File
@@ -75,6 +75,45 @@ pub fn append_nudge(messages: &mut Vec<Message>, extra: &mut Vec<Message>, text:
}
}
/// La clé `NUDGE_IN_TOOL` est-elle posée (`nudgeInToolOn`, sans la sonde) ?
pub fn nudge_in_tool_key(cfg: &std::collections::HashMap<String, String>) -> bool {
matches!(
cfg.get("NUDGE_IN_TOOL")
.map(|v| v.trim().to_lowercase())
.as_deref(),
Some("on" | "1" | "true" | "yes" | "oui")
)
}
/// `nudgeIntoTool` : le rappel au bout du dernier résultat d'outil, dans la
/// vue du modèle ET dans l'historique, à l'identique. `false` : dernier
/// message d'une autre forme, ou historique réécrit entre-temps — l'appelant
/// passe alors par [`append_nudge`].
pub fn nudge_into_tool(messages: &mut [Message], extra: &mut [Message], text: &str) -> bool {
let (Some(last), Some(kept)) = (messages.last(), extra.last()) else {
return false;
};
let (Some(Value::String(s)), Some(Value::String(ks))) = (&last.content, &kept.content) else {
return false;
};
if s != ks
|| last.role != "tool"
|| kept.role != "tool"
|| last.tool_call_id != kept.tool_call_id
{
return false;
}
let mut m = last.clone();
m.content = Some(Value::String(format!("{s}\n\n{text}")));
if let Some(l) = extra.last_mut() {
*l = m.clone();
}
if let Some(l) = messages.last_mut() {
*l = m;
}
true
}
/// `steerSystem` : la consigne s'ajoute au système de tête (texte), ou
/// devient un système en tête — jamais un système en fin de fil, que les
/// gabarits stricts refusent.
@@ -224,4 +263,54 @@ mod tests {
(TOOL_PREVIEW_CHARS, TOOL_PREVIEW_CHARS + 5, true)
);
}
#[test]
fn rappel_dans_le_resultat_d_outil() {
let tm = |c: &str| Message {
role: "tool".into(),
content: Some(Value::String(c.into())),
tool_call_id: "t1".into(),
..Default::default()
};
let um = |c: &str| Message {
role: "user".into(),
content: Some(Value::String(c.into())),
..Default::default()
};
let mut msgs = vec![um("q"), tm("res")];
let mut extra = vec![tm("res")];
assert!(nudge_into_tool(&mut msgs, &mut extra, "[system] stop"));
assert_eq!(
msgs[1].content,
Some(Value::String("res\n\n[system] stop".into()))
);
assert_eq!(msgs[1], extra[0]);
// Dernier message d'une autre forme, ou historique réécrit.
let mut m2 = vec![tm("res"), um("img")];
assert!(!nudge_into_tool(&mut m2, &mut [tm("res")], "x"));
assert!(!nudge_into_tool(&mut [tm("res")], &mut [], "x"));
assert!(!nudge_into_tool(&mut [tm("res")], &mut [tm("autre")], "x"));
let mut other_id = tm("res");
other_id.tool_call_id = "t2".into();
assert!(!nudge_into_tool(&mut [tm("res")], &mut [other_id], "x"));
let mut empty = tm("");
empty.content = None;
assert!(!nudge_into_tool(&mut [empty.clone()], &mut [empty], "x"));
}
#[test]
fn cle_nudge_in_tool() {
for (v, on) in [
("on", true),
(" TRUE ", true),
("oui", true),
("off", false),
("", false),
] {
let cfg =
std::collections::HashMap::from([("NUDGE_IN_TOOL".to_string(), v.to_string())]);
assert_eq!(nudge_in_tool_key(&cfg), on, "{v}");
}
assert!(!nudge_in_tool_key(&std::collections::HashMap::new()));
}
}
+915
View File
@@ -0,0 +1,915 @@
//! Sonde de gabarit (lot 23 de la migration) — port de `chat_tplprobe.go`.
//!
//! Que fait le gabarit de chat du modèle chargé de ce qu'on lui envoie ?
//! - `preserves_history` : le raisonnement d'un tour PASSÉ est-il rendu ?
//! - `prefix_stable` : après un tour d'outil, le rendu précédent reste-t-il un
//! préfixe exact du suivant ?
//! - `renders_reasoning` : `reasoning_content` est-il rendu quelque part ?
//!
//! Mêmes règles de conduite que le Go : moteur local seulement, rien d'autre
//! que `POST /apply-template` (sans état, sans slot), lancée après une
//! complétion terminée, avec les outils et `chat_template_kwargs` de cette
//! complétion ; tout échec donne « unknown ».
//!
//! Le relevé du rendu (`tplRenderPrint`) et sa comparaison (`tplRenderDiff`)
//! servent la vérification autour d'une mise à jour du moteur.
use std::collections::HashMap;
use std::sync::Mutex;
use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH};
use serde::Serialize;
use serde_json::value::RawValue;
use serde_json::{Map, Value, json};
use super::types::{Tool, ToolCall, ToolCallFunc};
use super::wire::TplTri;
use crate::gojson;
/// Délai de chaque requête (`tplProbeTimeout`).
pub const PROBE_TIMEOUT: Duration = Duration::from_secs(3);
/// Au plus une vérification par minute (`tplProbeEvery`).
pub const PROBE_EVERY: Duration = Duration::from_secs(60);
const CACHE_MAX: usize = 16;
const TRIES_503: u32 = 3;
const BODY_MAX: usize = 8 << 20;
const MARK_SYS: &str = "loki-sonde-systeme";
const MARK_A: &str = "loki-sonde-question-a";
const MARK_R1: &str = "loki-sonde-raisonnement-r1";
const MARK_C1: &str = "loki-sonde-reponse-c1";
const MARK_T1: &str = "loki-sonde-resultat-t1";
const MARK_B: &str = "loki-sonde-question-b";
const CALL_ID: &str = "a1B2c3D4e";
/// `tplRenderSep`.
pub const RENDER_SEP: &str = "\n\x00--- ";
impl TplTri {
pub fn as_str(self) -> &'static str {
match self {
TplTri::Yes => "yes",
TplTri::No => "no",
TplTri::Unknown => "unknown",
}
}
}
/// `tplProbeResult`.
#[derive(Debug, Clone, PartialEq)]
pub struct ProbeResult {
pub preserves_history: TplTri,
pub prefix_stable: TplTri,
pub renders_reasoning: TplTri,
pub build: String,
pub model: String,
pub template_hash: String,
pub tools: usize,
pub kwargs: Option<Map<String, Value>>,
pub effort: String,
pub note: String,
pub at: SystemTime,
/// La réponse tient tant que moteur, modèle et gabarit restent les mêmes.
pub cacheable: bool,
}
impl ProbeResult {
/// `tplUnknownResult`.
pub fn unknown(note: &str) -> Self {
ProbeResult {
preserves_history: TplTri::Unknown,
prefix_stable: TplTri::Unknown,
renders_reasoning: TplTri::Unknown,
build: String::new(),
model: String::new(),
template_hash: String::new(),
tools: 0,
kwargs: None,
effort: String::new(),
note: note.into(),
at: SystemTime::now(),
cacheable: false,
}
}
/// Le JSON du Go (champs dans l'ordre de la struct, `omitempty` compris).
pub fn to_json(&self) -> Value {
let mut m = Map::new();
m.insert(
"preserves_history".into(),
json!(self.preserves_history.as_str()),
);
m.insert("prefix_stable".into(), json!(self.prefix_stable.as_str()));
m.insert(
"renders_reasoning".into(),
json!(self.renders_reasoning.as_str()),
);
for (k, v) in [
("build", &self.build),
("model", &self.model),
("template_hash", &self.template_hash),
] {
if !v.is_empty() {
m.insert(k.into(), json!(v));
}
}
m.insert("tools".into(), json!(self.tools));
if let Some(k) = self.kwargs.as_ref().filter(|k| !k.is_empty()) {
m.insert("chat_template_kwargs".into(), Value::Object(k.clone()));
}
if !self.effort.is_empty() {
m.insert("reasoning_effort".into(), json!(self.effort));
}
if !self.note.is_empty() {
m.insert("note".into(), json!(self.note));
}
m.insert("at".into(), json!(rfc3339(self.at)));
Value::Object(m)
}
/// Le sous-ensemble que lit la politique d'écho.
pub fn verdict(&self) -> super::wire::TplVerdict {
super::wire::TplVerdict {
model: self.model.clone(),
renders_reasoning: self.renders_reasoning,
preserves_history: self.preserves_history,
}
}
}
/// Instant au format RFC 3339 (UTC, nanosecondes), comme `time.Time`.
pub fn rfc3339(t: SystemTime) -> String {
let d = t.duration_since(UNIX_EPOCH).unwrap_or_default();
let secs = d.as_secs() as i64;
let (days, rem) = (secs.div_euclid(86_400), secs.rem_euclid(86_400));
// Jours depuis 1970 → date civile (algorithme de Howard Hinnant).
let z = days + 719_468;
let era = z.div_euclid(146_097);
let doe = z - era * 146_097;
let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365;
let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
let mp = (5 * doy + 2) / 153;
let day = doy - (153 * mp + 2) / 5 + 1;
let month = if mp < 10 { mp + 3 } else { mp - 9 };
let year = yoe + era * 400 + i64::from(month <= 2);
let mut s = format!(
"{year:04}-{month:02}-{day:02}T{:02}:{:02}:{:02}",
rem / 3600,
rem % 3600 / 60,
rem % 60
);
let ns = d.subsec_nanos();
if ns > 0 {
let frac = format!("{ns:09}");
s.push('.');
s.push_str(frac.trim_end_matches('0'));
}
s.push('Z');
s
}
/// `tplShape` : la forme de la requête à reproduire.
#[derive(Debug, Clone, PartialEq, Default)]
pub struct Shape {
/// Outils figés en JSON ; `None` = aucun outil envoyé.
pub tools: Option<String>,
pub n_tools: usize,
pub first: String,
pub kwargs: Option<Map<String, Value>>,
pub effort: String,
}
impl Shape {
/// `newTplShape`.
pub fn new(tools: &[Tool], kwargs: Option<&Map<String, Value>>, effort: &str) -> Self {
let mut s = Shape {
n_tools: tools.len(),
effort: effort.into(),
..Shape::default()
};
if !tools.is_empty() {
s.tools = Some(gojson::marshal(tools));
s.first = tools[0].function.name.clone();
}
s.kwargs = kwargs.cloned();
s
}
/// `tplShape.hash` : FNV-64a des outils, des kwargs et de l'effort.
pub fn hash(&self) -> String {
let mut h = Fnv::new();
if let Some(t) = &self.tools {
h.write(t.as_bytes());
}
h.write(&[0]);
let kw = match &self.kwargs {
Some(k) => gojson::marshal(&Value::Object(k.clone())),
None => "null".into(),
};
h.write(kw.as_bytes());
h.write(&[0]);
h.write(self.effort.as_bytes());
format!("{:016x}", h.0)
}
}
/// FNV-1a 64 bits (`hash/fnv.New64a`).
struct Fnv(u64);
impl Fnv {
fn new() -> Self {
Fnv(0xcbf2_9ce4_8422_2325)
}
fn write(&mut self, b: &[u8]) {
for &c in b {
self.0 ^= u64::from(c);
self.0 = self.0.wrapping_mul(0x0000_0100_0000_01b3);
}
}
}
fn fnv_hex(b: &[u8]) -> String {
let mut h = Fnv::new();
h.write(b);
format!("{:016x}", h.0)
}
/// `tplMsg`.
#[derive(Serialize, Clone)]
struct Msg {
role: &'static str,
content: &'static str,
#[serde(skip_serializing_if = "str::is_empty")]
reasoning_content: &'static str,
#[serde(skip_serializing_if = "Vec::is_empty")]
tool_calls: Vec<ToolCall>,
#[serde(skip_serializing_if = "str::is_empty")]
tool_call_id: &'static str,
}
fn msg(role: &'static str, content: &'static str) -> Msg {
Msg {
role,
content,
reasoning_content: "",
tool_calls: Vec::new(),
tool_call_id: "",
}
}
fn call(name: &str) -> ToolCall {
ToolCall {
index: None,
id: CALL_ID.into(),
kind: "function".into(),
function: ToolCallFunc {
name: name.into(),
arguments: "{}".into(),
},
}
}
/// Réponse du moteur : non-200 (`tplHTTPError`) ou autre échec.
#[derive(Debug, Clone, PartialEq)]
pub enum ProbeError {
Http { status: u16, body: String },
Other(String),
}
impl std::fmt::Display for ProbeError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
ProbeError::Http { status, body } if !body.is_empty() => {
write!(f, "HTTP {status} : {body}")
}
ProbeError::Http { status, .. } => write!(f, "HTTP {status}"),
ProbeError::Other(e) => f.write_str(e),
}
}
}
impl ProbeError {
/// `tplDefinitive` : l'échec se reproduira à l'identique.
fn definitive(&self) -> bool {
match self {
ProbeError::Http { status, .. } => {
!matches!(status, 401 | 403 | 503 | 429) && (400..600).contains(status)
}
ProbeError::Other(_) => false,
}
}
}
/// `/props` : ce que la sonde en retient.
#[derive(Debug, Clone, Default, PartialEq)]
pub struct Props {
pub build_info: String,
pub model_path: String,
pub chat_template: String,
}
/// `tplProber` : le moteur local.
#[derive(Clone)]
pub struct Prober {
pub base: String,
/// Clé Bearer du moteur ("" = aucune).
pub key: String,
pub client: reqwest::Client,
/// Attente entre deux essais sur un 503 (`tplProbeBackoff`).
pub backoff: Duration,
}
impl Prober {
/// `tplLocalProber`.
pub fn local(port: i64, key: &str, backoff: Duration) -> Self {
crate::chat::engine::install_crypto();
Prober {
base: format!("http://localhost:{port}"),
key: key.into(),
client: reqwest::Client::builder()
.timeout(PROBE_TIMEOUT)
.build()
.unwrap_or_default(),
backoff,
}
}
async fn call(
&self,
method: reqwest::Method,
path: &str,
body: Option<Vec<u8>>,
) -> Result<Vec<u8>, ProbeError> {
let mut rq = self
.client
.request(method, format!("{}{path}", self.base))
.timeout(PROBE_TIMEOUT);
if let Some(b) = body {
rq = rq.header("Content-Type", "application/json").body(b);
}
if !self.key.is_empty() {
rq = rq.header("Authorization", format!("Bearer {}", self.key));
}
let mut resp = rq
.send()
.await
.map_err(|e| ProbeError::Other(e.to_string()))?;
let status = resp.status().as_u16();
let mut data = Vec::new();
while data.len() < BODY_MAX {
match resp.chunk().await {
Ok(Some(c)) => data.extend_from_slice(&c),
Ok(None) => break,
Err(e) => return Err(ProbeError::Other(e.to_string())),
}
}
data.truncate(BODY_MAX);
if status != 200 {
let msg = String::from_utf8_lossy(&data).trim().to_string();
let msg = if msg.chars().count() > 160 {
format!("{}…", msg.chars().take(160).collect::<String>())
} else {
msg
};
return Err(ProbeError::Http { status, body: msg });
}
Ok(data)
}
/// `/health` à 200 ?
pub async fn healthy(&self) -> bool {
self.call(reqwest::Method::GET, "/health", None)
.await
.is_ok()
}
/// `/props`.
pub async fn props(&self) -> Result<Props, ProbeError> {
let data = self.call(reqwest::Method::GET, "/props", None).await?;
let bad = || ProbeError::Other("réponse non JSON".into());
let v: Value = serde_json::from_slice(&data).map_err(|_| bad())?;
let mut p = Props::default();
match v {
Value::Null => return Ok(p),
Value::Object(m) => {
for (k, dst) in [
("build_info", &mut p.build_info),
("model_path", &mut p.model_path),
("chat_template", &mut p.chat_template),
] {
match m.get(k) {
None | Some(Value::Null) => {}
Some(Value::String(s)) => *dst = s.clone(),
Some(_) => return Err(bad()),
}
}
}
_ => return Err(bad()),
}
Ok(p)
}
/// `render` : POST /apply-template, relancé sur 503.
async fn render(
&self,
shape: &Shape,
msgs: &[Msg],
gen_prompt: bool,
) -> Result<String, ProbeError> {
#[derive(Serialize)]
struct Body<'a> {
add_generation_prompt: bool,
#[serde(skip_serializing_if = "Option::is_none")]
chat_template_kwargs: Option<&'a Map<String, Value>>,
messages: &'a [Msg],
#[serde(skip_serializing_if = "Option::is_none")]
parallel_tool_calls: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
reasoning_effort: Option<&'a str>,
#[serde(skip_serializing_if = "Option::is_none")]
tools: Option<&'a RawValue>,
}
let raw_tools = shape
.tools
.as_deref()
.and_then(|t| serde_json::from_str::<&RawValue>(t).ok());
let raw = gojson::marshal(&Body {
add_generation_prompt: gen_prompt,
chat_template_kwargs: shape.kwargs.as_ref(),
messages: msgs,
parallel_tool_calls: raw_tools.map(|_| false),
reasoning_effort: (!shape.effort.is_empty()).then_some(shape.effort.as_str()),
tools: raw_tools,
});
let mut tries = 1;
loop {
match self
.call(
reqwest::Method::POST,
"/apply-template",
Some(raw.clone().into_bytes()),
)
.await
{
Err(ProbeError::Http { status: 503, .. }) if tries < TRIES_503 => {
tokio::time::sleep(self.backoff * tries).await;
tries += 1;
}
Err(e) => return Err(e),
Ok(data) => {
let prompt = serde_json::from_slice::<Value>(&data)
.ok()
.and_then(|v| v.get("prompt").cloned());
return match prompt {
Some(Value::String(p)) => Ok(p),
_ => Err(ProbeError::Http {
status: 200,
body: "réponse sans prompt".into(),
}),
};
}
}
}
}
/// `probe` : les rendus et leurs verdicts.
pub async fn probe(&self, shape: &Shape) -> ProbeResult {
let mut r = ProbeResult::unknown("");
let mut definitive = true;
let mut notes: Vec<String> = Vec::new();
let mut fail = |what: &str, e: &ProbeError, notes: &mut Vec<String>| {
if !e.definitive() {
definitive = false;
}
notes.push(format!("{what} : {e}"));
};
let sys = msg("system", MARK_SYS);
let user_a = msg("user", MARK_A);
let user_b = msg("user", MARK_B);
let mut past = msg("assistant", MARK_C1);
past.reasoning_content = MARK_R1;
// 1) Raisonnement d'un tour passé.
match self
.render(
shape,
&[sys.clone(), user_a.clone(), past, user_b.clone()],
false,
)
.await
{
Err(e) => fail("historique", &e, &mut notes),
Ok(h) if !h.contains(MARK_C1) || !h.contains(MARK_B) => {
notes.push("historique : rendu incohérent".into());
}
Ok(h) if h.contains(MARK_R1) => r.preserves_history = TplTri::Yes,
Ok(_) => r.preserves_history = TplTri::No,
}
// 2) Stabilité du préfixe à travers un tour d'outil.
let name = if shape.first.is_empty() {
"lookup"
} else {
&shape.first
};
let mut c = msg("assistant", MARK_C1);
c.reasoning_content = MARK_R1;
c.tool_calls = vec![call(name)];
let mut result = msg("tool", MARK_T1);
result.tool_call_id = CALL_ID;
let before = vec![sys, user_a, c, result];
let mut after = before.clone();
after.push(user_b);
let r1 = self.render(shape, &before, false).await;
let mut r1g: Result<String, ProbeError> = Err(ProbeError::Other(String::new()));
let mut r2: Result<String, ProbeError> = Err(ProbeError::Other(String::new()));
if r1.is_ok() {
r1g = self.render(shape, &before, true).await;
}
if r1.is_ok() && r1g.is_ok() {
r2 = self.render(shape, &after, false).await;
}
match (&r1, &r1g, &r2) {
(Err(e), _, _) | (Ok(_), Err(e), _) | (Ok(_), Ok(_), Err(e)) => {
fail("préfixe", e, &mut notes)
}
(Ok(a), Ok(g), _) if a == g => {
notes.push("add_generation_prompt ignoré par le moteur".into())
}
(Ok(a), Ok(_), Ok(b)) if !a.contains(MARK_T1) || !b.contains(MARK_B) => {
notes.push("préfixe : rendu incohérent".into())
}
(Ok(a), Ok(_), Ok(b)) if b.starts_with(a.as_str()) => r.prefix_stable = TplTri::Yes,
_ => r.prefix_stable = TplTri::No,
}
let r1_has = |m: &str| r1.as_ref().is_ok_and(|s| s.contains(m));
if r.preserves_history == TplTri::Yes || r1_has(MARK_R1) {
r.renders_reasoning = TplTri::Yes;
} else if r1_has(MARK_T1) && r.preserves_history == TplTri::No {
r.renders_reasoning = TplTri::No;
notes.push("reasoning_content jamais rendu".into());
}
r.note = notes.join(" ; ");
r.cacheable = definitive;
r
}
/// `tplRenderPrint` : les rendus concaténés de trois conversations
/// synthétiques sur la forme donnée.
pub async fn render_print(&self, shape: &Shape) -> Result<String, String> {
let sys = msg("system", MARK_SYS);
let user_a = msg("user", MARK_A);
let user_b = msg("user", MARK_B);
let name = if shape.first.is_empty() {
"lookup"
} else {
&shape.first
};
let mut with_r = msg("assistant", MARK_C1);
with_r.reasoning_content = MARK_R1;
let mut c = msg("assistant", MARK_C1);
c.tool_calls = vec![call(name)];
let mut result = msg("tool", MARK_T1);
result.tool_call_id = CALL_ID;
let convs: [(&str, Vec<Msg>); 3] = [
(
"historique sans raisonnement",
vec![
sys.clone(),
user_a.clone(),
msg("assistant", MARK_C1),
user_b.clone(),
],
),
(
"historique avec raisonnement",
vec![sys.clone(), user_a.clone(), with_r, user_b.clone()],
),
("tour d'outil", vec![sys, user_a, c, result, user_b]),
];
let mut b = String::new();
for (name, msgs) in convs {
let out = self
.render(shape, &msgs, true)
.await
.map_err(|e| format!("{name} : {e}"))?;
b.push_str(&format!("{RENDER_SEP}{name} ---\n"));
b.push_str(&out);
}
Ok(b)
}
}
/// `tplRenderDiff` : identiques → (true, "") ; sinon la conversation où les
/// relevés divergent et un extrait de part et d'autre du premier écart.
pub fn render_diff(before: &str, after: &str) -> (bool, String) {
if before == after {
return (true, String::new());
}
let (bb, ab) = (before.as_bytes(), after.as_bytes());
let mut i = 0;
while i < bb.len() && i < ab.len() && bb[i] == ab[i] {
i += 1;
}
let mut section = String::new();
if let Some(k) = find_last(&bb[..i], RENDER_SEP.as_bytes()) {
let rest = &bb[k + RENDER_SEP.len()..];
if let Some(e) = find(rest, b" ---") {
section = String::from_utf8_lossy(&rest[..e]).into_owned();
}
}
let excerpt = |s: &[u8]| -> String {
let mut from = i.saturating_sub(40);
let mut to = (i + 60).min(s.len());
while from > 0 && !rune_start(s[from]) {
from -= 1;
}
while to < s.len() && !rune_start(s[to]) {
to += 1;
}
let mut x = String::from_utf8_lossy(&s[from..to])
.replace('\n', "⏎")
.replace('\x00', "");
if from > 0 {
x = format!("…{x}");
}
if to < s.len() {
x.push('…');
}
x
};
let mut d = format!("avant « {} » / après « {} »", excerpt(bb), excerpt(ab));
if !section.is_empty() {
d = format!("{section} : {d}");
}
(false, d)
}
fn rune_start(b: u8) -> bool {
b & 0xC0 != 0x80
}
fn find(h: &[u8], n: &[u8]) -> Option<usize> {
h.windows(n.len()).position(|w| w == n)
}
fn find_last(h: &[u8], n: &[u8]) -> Option<usize> {
if h.len() < n.len() {
return None;
}
(0..=h.len() - n.len())
.rev()
.find(|&i| &h[i..i + n.len()] == n)
}
/// `tplProbeLine`.
pub fn probe_line(r: &ProbeResult) -> String {
let dash = |s: &str| {
if s.is_empty() {
"-".to_string()
} else {
s.to_string()
}
};
let mut s = format!(
"[tplprobe] build={} modèle={} gabarit={} outils={} historique_raisonnement={} préfixe_stable={} raisonnement_rendu={}",
dash(&r.build),
dash(&r.model),
dash(&r.template_hash),
r.tools,
r.preserves_history.as_str(),
r.prefix_stable.as_str(),
r.renders_reasoning.as_str()
);
if !r.note.is_empty() {
s.push_str(&format!(" ({})", r.note));
}
s
}
/// Où sonder : le moteur local, sa clé, et si un preset externe est actif.
#[derive(Debug, Clone, Default)]
pub struct Env {
pub external: bool,
pub port: i64,
pub key: String,
}
#[derive(Default)]
struct State {
cache: HashMap<String, ProbeResult>,
last: Option<ProbeResult>,
by_shape: HashMap<String, ProbeResult>,
running: bool,
tried: Option<Instant>,
logged: String,
shape_last: Option<Shape>,
}
/// La sonde : son cache et ses derniers verdicts (l'état global du Go).
pub struct TplProbe {
state: Mutex<State>,
/// Déclenchement automatique après les complétions (`tplProbeAuto`).
pub auto: bool,
/// Attente entre deux essais sur un 503.
pub backoff: Duration,
/// Intervalle minimal entre deux vérifications automatiques.
pub every: Duration,
}
impl Default for TplProbe {
fn default() -> Self {
TplProbe::new()
}
}
/// Le plus ancien verdict d'une table (pour lui faire de la place).
fn oldest(m: &HashMap<String, ProbeResult>) -> Option<String> {
m.iter().min_by_key(|(_, v)| v.at).map(|(k, _)| k.clone())
}
impl TplProbe {
pub fn new() -> Self {
TplProbe {
state: Mutex::new(State::default()),
auto: true,
backoff: Duration::from_secs(1),
every: PROBE_EVERY,
}
}
fn st(&self) -> std::sync::MutexGuard<'_, State> {
self.state.lock().unwrap_or_else(|e| e.into_inner())
}
/// `tplLastShape` : la forme de la dernière requête sondée, ou une
/// requête nue.
pub fn last_shape(&self) -> Shape {
self.st().shape_last.clone().unwrap_or_default()
}
/// `tplCapsCurrent` : le dernier verdict, rien pour un preset externe.
pub fn caps_current(&self, external: bool) -> Option<ProbeResult> {
if external {
return None;
}
self.st().last.clone()
}
/// `tplCapsFor` : le dernier verdict pour CETTE forme.
pub fn caps_for(&self, external: bool, shape: &Shape) -> Option<ProbeResult> {
if external {
return None;
}
self.st().by_shape.get(&shape.hash()).cloned()
}
/// Pose le verdict d'une forme comme si la sonde venait de conclure
/// (tests, et reprise d'un verdict connu).
pub fn remember(&self, shape: &Shape, r: ProbeResult) {
let mut st = self.st();
st.last = Some(r.clone());
Self::keep_shape(&mut st, &shape.hash(), &r);
}
/// `tplProbeKeepShape` (verrou tenu).
fn keep_shape(st: &mut State, shape: &str, r: &ProbeResult) {
if !st.by_shape.contains_key(shape)
&& st.by_shape.len() >= CACHE_MAX
&& let Some(k) = oldest(&st.by_shape)
{
st.by_shape.remove(&k);
}
st.by_shape.insert(shape.into(), r.clone());
}
/// `tplProbeStore`.
fn store(&self, key: &str, shape: &str, r: &ProbeResult) {
let mut st = self.st();
st.last = Some(r.clone());
Self::keep_shape(&mut st, shape, r);
if key.is_empty() || !r.cacheable {
return;
}
if st.cache.len() >= CACHE_MAX
&& let Some(k) = oldest(&st.cache)
{
st.cache.remove(&k);
}
st.cache.insert(key.into(), r.clone());
}
/// Le prober du moteur local.
pub fn prober(&self, env: &Env) -> Prober {
Prober::local(env.port, &env.key, self.backoff)
}
/// `tplProbeEnsure` : verdict pour la forme, depuis le cache ou une sonde.
pub async fn ensure(&self, env: &Env, shape: &Shape) -> ProbeResult {
if env.external {
return ProbeResult::unknown("preset externe : pas de sonde");
}
self.st().shape_last = Some(shape.clone());
let p = self.prober(env);
if !p.healthy().await {
return ProbeResult::unknown("moteur pas prêt (/health)");
}
let props = match p.props().await {
Ok(pr) => pr,
Err(e) => {
let r = ProbeResult::unknown(&format!("/props illisible : {e}"));
self.store("", &shape.hash(), &r);
return r;
}
};
let tmpl_hash = fnv_hex(props.chat_template.as_bytes());
let key = format!(
"{}\x00{}\x00{tmpl_hash}\x00{}",
props.build_info,
props.model_path,
shape.hash()
);
{
let mut st = self.st();
if let Some(r) = st.cache.get(&key).cloned() {
st.last = Some(r.clone());
Self::keep_shape(&mut st, &shape.hash(), &r);
return r;
}
}
let mut r = p.probe(shape).await;
r.build = props.build_info.clone();
r.template_hash = tmpl_hash;
r.tools = shape.n_tools;
r.kwargs = shape.kwargs.clone();
r.effort = shape.effort.clone();
if !props.model_path.is_empty() {
r.model = crate::paths::base_name(&props.model_path);
}
let line = probe_line(&r);
let fresh = {
let mut st = self.st();
let fresh = line != st.logged;
st.logged = line.clone();
fresh
};
if fresh {
eprintln!("{line}");
}
self.store(&key, &shape.hash(), &r);
r
}
/// `tplProbeKick` : après une complétion complète du fil principal, en
/// tâche de fond ; au plus une sonde en vol, au plus une par `every`.
pub fn kick(self: &std::sync::Arc<Self>, env: Env, shape: Shape) {
if !self.auto {
return;
}
{
let mut st = self.st();
if st.running || st.tried.is_some_and(|t| t.elapsed() < self.every) {
return;
}
st.running = true;
st.tried = Some(Instant::now());
}
let me = self.clone();
tokio::spawn(async move {
let _ = tokio::time::timeout(Duration::from_secs(30), me.ensure(&env, &shape)).await;
me.st().running = false;
});
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn fnv64a() {
// Vecteurs de référence FNV-1a 64 bits.
assert_eq!(fnv_hex(b""), "cbf29ce484222325");
assert_eq!(fnv_hex(b"a"), "af63dc4c8601ec8c");
}
#[test]
fn instant_rfc3339() {
assert_eq!(rfc3339(UNIX_EPOCH), "1970-01-01T00:00:00Z");
assert_eq!(
rfc3339(UNIX_EPOCH + Duration::new(1_709_251_200, 500_000_000)),
"2024-03-01T00:00:00.5Z"
);
}
#[test]
fn json_omitempty() {
let mut r = ProbeResult::unknown("");
r.kwargs = Some(Map::new());
let v = r.to_json();
assert!(v.get("chat_template_kwargs").is_none());
assert!(v.get("note").is_none());
assert_eq!(v["tools"], 0);
}
}
+35 -4
View File
@@ -24,9 +24,9 @@
//! l'historique ([`TurnResult::extra`]).
//!
//! Non portés (clés opt-in, ou machinerie du moteur local restée au Go) :
//! KEEP_TURN_IMAGES, PROJ_SNAPSHOT, NUDGE_IN_TOOL, SIDE_SLOT (slot et
//! rejeux « cache KV plein »), SLOT_PERSIST, préchauffage, sonde de gabarit,
//! télémétrie `perfRecord`, comptage des requêtes en vol du moteur. Avec l'une
//! KEEP_TURN_IMAGES, PROJ_SNAPSHOT, SIDE_SLOT (slot et rejeux « cache KV
//! plein »), SLOT_PERSIST, préchauffage, télémétrie `perfRecord`, comptage
//! des requêtes en vol du moteur. Avec l'une
//! de ces clés, le Go garde la conversation (`facadeChatBlocker`, décidé au
//! démarrage).
@@ -152,6 +152,20 @@ pub trait TurnHost: Send + Sync {
}
/// Messages mis en file par l'utilisateur, à injecter maintenant.
fn drain_queued(&self) -> Vec<Message>;
/// Complétion lue en entier sur le moteur local, outils ni coupés ni
/// neutralisés : la sonde de gabarit peut rejouer sa forme
/// (`tplProbeKick`).
fn completed(&self, _tools: &[Tool], _kwargs: Option<&Map<String, Value>>, _effort: &str) {}
/// La sonde a-t-elle conclu « préfixe instable » pour CE modèle et pour
/// cette forme de requête (`nudgeInToolOn`, hors clé et preset externe) ?
fn prefix_unstable(
&self,
_tools: &[Tool],
_kwargs: Option<&Map<String, Value>>,
_effort: &str,
) -> bool {
false
}
/// Prépare ce que [`Self::image_data_url`] devra rendre pour ces messages
/// (lecture et déchiffrement des images rangées), avant chaque requête.
fn prepare_images(&self, _msgs: &[Message]) -> impl Future<Output = ()> + Send {
@@ -384,7 +398,19 @@ pub async fn run_turn<H: TurnHost>(
if !nudge.is_empty() {
s.budget_nudges += 1;
log_budget(s.tool_runs, budget, s.budget_nudges);
append_nudge(&mut messages, &mut extra, &nudge);
// NUDGE_IN_TOOL (opt-in, gabarits dont le rendu bouge quand un
// message user s'ajoute) : au bout du dernier résultat d'outil.
// La sonde ne connaît que la forme normale : une relance d'une
// autre forme se replie sur le message à part.
let placed = !s.disable_tools
&& !s.tool_choice_none
&& super::nudges::nudge_in_tool_key(&tc.cfg)
&& !ep.external
&& host.prefix_unstable(&tc.tools, kwargs.as_ref(), &effort)
&& super::nudges::nudge_into_tool(&mut messages, &mut extra, &nudge);
if !placed {
append_nudge(&mut messages, &mut extra, &nudge);
}
}
let calls_on = !tc.tools.is_empty() && !s.disable_tools && !s.tool_choice_none;
let pol = turn_echo_policy(ep.external || s.echo_off_turn, || tc.echo.clone());
@@ -585,6 +611,11 @@ pub async fn run_turn<H: TurnHost>(
if out.is_complete(read_err.is_some()) {
// Dernier événement de la complétion : copie complète des stats.
cb(StreamEvent::Stats(out.stats.clone()));
// Sonde de gabarit, en tâche de fond et après coup : rien n'en
// revient dans la requête suivante.
if !ep.external && !s.disable_tools && !s.tool_choice_none {
host.completed(&tc.tools, kwargs.as_ref(), &effort);
}
if !ep.external {
s.peak_gen = s.peak_gen.max(out.stats.gen_tokens);
if s.last_est > 0 && out.stats.prompt_tokens_total > 0 {
+125
View File
@@ -17,6 +17,9 @@ enum Script {
Cut(Vec<String>),
}
/// Une complétion signalée à la sonde : (outils, kwargs, effort).
type Completion = (usize, Option<Map<String, Value>>, String);
#[derive(Default)]
struct Fake {
script: Mutex<VecDeque<Script>>,
@@ -29,6 +32,10 @@ struct Fake {
efforts: Mutex<Vec<(String, String)>>,
/// L'arrêt à déclencher pendant l'exécution d'un outil.
stop_on_exec: Mutex<Option<watch::Sender<bool>>>,
/// Verdict « préfixe instable » de la sonde (NUDGE_IN_TOOL).
unstable: Mutex<bool>,
/// Complétions signalées à la sonde : (outils, kwargs, effort).
completions: Mutex<Vec<Completion>>,
}
impl Fake {
@@ -127,6 +134,15 @@ impl TurnHost for Fake {
self.efforts.lock().unwrap().push((want.into(), got.into()));
}
fn echo_refuse(&self, _model: &str) {}
fn completed(&self, tools: &[Tool], kwargs: Option<&Map<String, Value>>, effort: &str) {
self.completions
.lock()
.unwrap()
.push((tools.len(), kwargs.cloned(), effort.into()));
}
fn prefix_unstable(&self, _t: &[Tool], _k: Option<&Map<String, Value>>, _e: &str) -> bool {
*self.unstable.lock().unwrap()
}
}
fn content(s: &str) -> String {
@@ -339,6 +355,17 @@ async fn erreur_500_tool_choice_none_puis_outils_retires() {
);
assert!(host.body(2).get("tools").is_none(), "puis retirés");
assert_eq!(host.body(2)["messages"][0]["role"], "system");
// Une relance outils coupés n'est pas la forme normale : pas de sonde.
assert!(host.completions.lock().unwrap().is_empty());
// Pas davantage outils neutralisés (tool_choice « none »).
let host = Fake::new(vec![
Script::Status(500, "parse error".into()),
answer("texte"),
]);
let (r, _) = run(&host, &config(&["glob"]), &Cancel::never()).await;
assert_eq!(r.err, None);
assert_eq!(host.body(1)["tool_choice"], "none");
assert!(host.completions.lock().unwrap().is_empty());
}
#[tokio::test]
@@ -471,6 +498,104 @@ async fn budget_d_outils_rappel() {
}));
}
/// NUDGE_IN_TOOL : avec la clé et un gabarit « préfixe instable », le rappel
/// part au bout du dernier résultat d'outil (envoyé et gardé à l'identique),
/// sans message user à part ; sans verdict, ou sans la clé, à part.
#[tokio::test]
async fn rappel_de_budget_dans_le_resultat() {
for (key, unstable, inside) in [
("on", true, true),
("on", false, false),
("", true, false),
("Oui", true, true),
("external", true, false),
] {
let mut script: Vec<Script> = (0..3)
.map(|i| {
tool_step(
&format!("c{i}"),
"bash",
&format!("{{\"command\":\"ls {i}\"}}"),
)
})
.collect();
script.push(answer("fin"));
let host = Fake::new(script);
*host.unstable.lock().unwrap() = unstable;
let mut tc = config(&["bash"]);
tc.cfg.insert("AGENT_BUDGET".into(), "2".into());
if key == "external" {
// Preset externe : le rappel reste à part, quoi que dise la sonde.
let ext = HashMap::from([
("EXTERNAL".to_string(), "1".to_string()),
(
"EXTERNAL_URL".to_string(),
"http://127.0.0.1:1/v1".to_string(),
),
]);
tc.ep = resolve_chat_endpoint(&ext, "");
tc.cfg.insert("NUDGE_IN_TOOL".into(), "on".into());
} else {
tc.cfg.insert("NUDGE_IN_TOOL".into(), key.into());
}
let (r, _) = run(&host, &tc, &Cancel::never()).await;
let msgs = host.body(2)["messages"].as_array().unwrap().clone();
let last = msgs.last().unwrap();
let ctx = format!("{key}/{unstable}");
if inside {
assert_eq!(last["role"], "tool", "{ctx}");
let c = last["content"].as_str().unwrap();
assert!(
c.starts_with("résultat de bash\n\n") && c.contains("2 tool calls"),
"{ctx} : {c}"
);
assert!(
!msgs.iter().any(|m| m["role"] == "user"
&& m["content"].as_str().unwrap_or("").contains("2 tool calls")),
"{ctx}"
);
let kept = r.extra.iter().filter(|m| m.role == "tool").nth(1).unwrap();
assert_eq!(
kept.content.as_ref().and_then(Value::as_str),
Some(c),
"{ctx} : historique"
);
} else {
assert_eq!(last["role"], "user", "{ctx}");
assert!(
last["content"].as_str().unwrap().contains("2 tool calls"),
"{ctx}"
);
}
}
}
/// Chaque complétion lue en entier est signalée à la sonde, avec la forme
/// de la requête ; pas sur un preset externe.
#[tokio::test]
async fn completions_signalees_a_la_sonde() {
let host = Fake::new(vec![tool_step("c1", "bash", "{}"), answer("fin")]);
let mut tc = config(&["bash"]);
tc.reasoning.effort = "low".into();
let (_, _) = run(&host, &tc, &Cancel::never()).await;
assert_eq!(
*host.completions.lock().unwrap(),
vec![(1, None, "low".to_string()), (1, None, "low".to_string())]
);
let host = Fake::new(vec![answer("fin")]);
let mut tc = config(&["bash"]);
let mut cfg = HashMap::new();
cfg.insert("EXTERNAL".to_string(), "1".to_string());
cfg.insert(
"EXTERNAL_URL".to_string(),
"http://127.0.0.1:1/v1".to_string(),
);
tc.ep = resolve_chat_endpoint(&cfg, "");
assert!(tc.ep.external);
let (_, _) = run(&host, &tc, &Cancel::never()).await;
assert!(host.completions.lock().unwrap().is_empty());
}
#[tokio::test]
async fn arret_pendant_un_outil() {
let host = Fake::new(vec![tool_step("c1", "bash", r#"{"command":"sleep 100"}"#)]);
+17
View File
@@ -65,6 +65,7 @@ pub fn is_internal(path: &str) -> bool {
| "/internal/facade/v1/reload"
| "/internal/facade/v1/project/switch"
| "/internal/facade/v1/project/drop"
| "/internal/facade/v1/chat/tpl"
)
}
@@ -90,6 +91,22 @@ pub async fn serve_internal(chat: &Arc<ChatService>, req: Request<Incoming>) ->
&json!({ "error": "événement illisible" }),
),
},
// Dernier verdict de la sonde de gabarit (`tplCapsCurrent`), pour le
// résumé des performances que sert encore le Go.
"/internal/facade/v1/chat/tpl" => {
let cfg = chat.shared.store.config().await;
match chat
.shared
.tpl
.caps_current(crate::engstatus::is_external(&cfg))
{
Some(r) => send_json(
StatusCode::OK,
&json!({ "ok": true, "result": r.to_json() }),
),
None => send_json(StatusCode::OK, &json!({ "ok": false })),
}
}
"/internal/facade/v1/project/switch" | "/internal/facade/v1/project/drop" => {
let v = read_json(req.into_body()).await.unwrap_or_default();
let slug = str_of(&v, "slug");