Merge pull request #34 from R0m1k3/fix/lot1-ajean-openfox

Correctifs repris d'AJEAN et d'OpenFox : LSP, shell, clé web, MCP, recall, compactage, relances
This commit is contained in:
LogiFlow authored and GitHub committed 2026-10-19 11:22:20 +02:00
commit 6a45f19831
24 files changed
+1104 -112

No files matched your search

+20 -3
View File
@@ -922,11 +922,19 @@ impl RetryPatterns {
if over.is_empty() {
return None;
}
let pats = over
// Un motif vide (ou blanc) est écarté : `(?mi)` correspond à TOUT, et
// chaque tour était relancé une fois (OpenFox 8e9b4869). Sans un seul
// motif utilisable, les motifs embarqués, comme le Go
// (compileRetryPatterns).
let pats: Vec<Box<dyn RetryPattern>> = over
.iter()
.filter(|p| !p.trim().is_empty())
.filter_map(|p| compile_go_regex(&format!("(?mi){p}")))
.map(|r| Box::new(GoRegex(r)) as Box<dyn RetryPattern>)
.collect();
if pats.is_empty() {
return None;
}
Some(RetryPatterns::custom(pats))
}
@@ -2907,10 +2915,19 @@ mod tests {
/// en `(?mi)`, sémantique du Go (classes et limites de mot ASCII).
#[test]
fn surcharge_retry_patterns() {
for raw in ["", "null", "[]", "[\"a\", 1]", "\"x\"", "[\"a\"] x"] {
for raw in [
"",
"null",
"[]",
"[\"a\", 1]",
"\"x\"",
"[\"a\"] x",
"[\"\"]",
"[\" \", \"(\"]",
] {
assert!(RetryPatterns::from_override(raw).is_none(), "{raw}");
}
let p = RetryPatterns::from_override(r#"["^oups", "[", "\\bdo_\\w+\\("]"#).unwrap();
let p = RetryPatterns::from_override(r#"["^oups", "[", "", "\\bdo_\\w+\\("]"#).unwrap();
assert!(p.is_custom());
assert_eq!(p.len(), 2, "la regex illisible est sautée");
assert!(p.textual_tool_call("Bonjour\nOUPS !"));
+102
View File
@@ -25,6 +25,14 @@ pub struct ServerConfig {
pub enabled: bool,
#[serde(rename = "disabledTools", skip_serializing_if = "Vec::is_empty")]
pub disabled_tools: Vec<String>,
/// `CallTimeoutSec` : délai d'un appel d'outil de ce serveur, en
/// secondes ; 0 = défaut ([`CALL_TIMEOUT`]).
#[serde(rename = "callTimeoutSec", skip_serializing_if = "is_zero")]
pub call_timeout_sec: i64,
}
fn is_zero(n: &i64) -> bool {
*n == 0
}
impl Default for ServerConfig {
@@ -37,6 +45,7 @@ impl Default for ServerConfig {
headers: BTreeMap::new(),
enabled: true,
disabled_tools: Vec::new(),
call_timeout_sec: 0,
}
}
}
@@ -78,6 +87,7 @@ impl<'de> Deserialize<'de> for ServerConfig {
Some(x) => x.as_bool().unwrap_or(false),
},
disabled_tools: list("disabledTools"),
call_timeout_sec: v.get("callTimeoutSec").and_then(Value::as_i64).unwrap_or(0),
})
}
}
@@ -282,6 +292,53 @@ pub const CONNECT_TIMEOUT: Duration = Duration::from_secs(20);
pub const CONNECT_TIMEOUT_COLD: Duration = Duration::from_secs(180);
/// `mcpCallTimeout`.
pub const CALL_TIMEOUT: Duration = Duration::from_secs(120);
/// `mcpCallTimeoutMax` : plafond d'un appel, quoi que demandent l'outil ou la
/// config.
pub const CALL_TIMEOUT_MAX: Duration = Duration::from_secs(30 * 60);
/// `mcpCallTimeoutFor` : [`CALL_TIMEOUT`], remplacé par `callTimeoutSec` du
/// serveur ; un argument `timeout` de l'outil l'allonge de 30 s.
pub fn call_timeout_for(cfg: &ServerConfig, args: &Map<String, Value>) -> Duration {
let mut d = CALL_TIMEOUT;
if cfg.call_timeout_sec > 0 {
d = Duration::from_secs(cfg.call_timeout_sec as u64);
}
if let Some(t) = arg_timeout(args) {
let t = t + Duration::from_secs(30);
if t > d {
d = t;
}
}
d.min(CALL_TIMEOUT_MAX)
}
/// `mcpArgTimeout` : l'argument `timeout` d'un appel. Au-delà de 3600, lu en
/// millisecondes (5000 = 5 s) ; en deçà, en secondes (600 = 10 min).
fn arg_timeout(args: &Map<String, Value>) -> Option<Duration> {
let v = match args.get("timeout")? {
Value::Number(n) => n.as_f64()?,
Value::String(s) => s.trim().parse::<f64>().ok()?,
_ => return None,
};
if !v.is_finite() || v <= 0.0 {
return None;
}
if v > 3600.0 {
// Plafonné bien avant tout débordement (CALL_TIMEOUT_MAX suit).
return Some(Duration::from_millis(v.min(1e12) as u64));
}
Some(Duration::from_secs_f64(v))
}
/// `mcpCall` pour un délai dépassé : le serveur est vivant mais l'outil est
/// lent, pas de nouvel essai (il doublerait l'attente, et l'effet de l'outil).
pub fn call_deadline_error(timeout: Duration) -> String {
let secs = (timeout.as_millis() + 500) / 1000;
format!(
"[erreur MCP] pas de réponse après {} (délai de l'appel ; un argument timeout plus grand l'allonge)",
crate::tools::shell::go_duration(Duration::from_secs(secs as u64))
)
}
/// Nom du lanceur (`filepath.Base`), en minuscules, sans `.exe` puis sans
/// `.cmd`.
@@ -463,6 +520,51 @@ fn resource_contents_json(v: &Value) -> String {
mod tests {
use super::*;
/// Les cas de `TestMCPCallTimeoutFor` côté Go.
#[test]
fn delai_d_appel() {
let cfg = |n: i64| ServerConfig {
call_timeout_sec: n,
..Default::default()
};
let args = |v: Value| {
let mut m = Map::new();
m.insert("timeout".into(), v);
m
};
let s = Duration::from_secs;
let none = Map::new();
assert_eq!(call_timeout_for(&cfg(0), &none), CALL_TIMEOUT);
assert_eq!(call_timeout_for(&cfg(600), &none), s(600));
assert_eq!(call_timeout_for(&cfg(0), &args(json!(300.0))), s(330));
assert_eq!(call_timeout_for(&cfg(0), &args(json!(200000.0))), s(230));
assert_eq!(
call_timeout_for(&cfg(0), &args(json!(5000.0))),
CALL_TIMEOUT
);
assert_eq!(call_timeout_for(&cfg(0), &args(json!("400"))), s(430));
assert_eq!(
call_timeout_for(&cfg(0), &args(json!(86400.0 * 1000.0))),
CALL_TIMEOUT_MAX
);
assert_eq!(call_timeout_for(&cfg(30), &none), s(30));
assert_eq!(
call_deadline_error(s(330)),
"[erreur MCP] pas de réponse après 5m30s (délai de l'appel ; un argument timeout plus grand l'allonge)"
);
// Le champ survit à un aller-retour JSON, absent quand il vaut 0.
let c: ServerConfig =
serde_json::from_value(json!({"command": "x", "callTimeoutSec": 45})).unwrap();
assert_eq!(c.call_timeout_sec, 45);
assert_eq!(serde_json::to_value(&c).unwrap()["callTimeoutSec"], 45);
assert!(
serde_json::to_value(cfg(0))
.unwrap()
.get("callTimeoutSec")
.is_none()
);
}
#[test]
fn noms() {
assert_eq!(sanitize("mon serveur!"), "mon_serveur");
+15 -5
View File
@@ -39,6 +39,9 @@ pub const INTERNAL_TOOLS: &str = "/internal/facade/v1/mcp/tools";
pub const INTERNAL_CALL: &str = "/internal/facade/v1/mcp/call";
pub const INTERNAL_PREWARM: &str = "/internal/facade/v1/mcp/prewarm";
/// Erreur d'un appel au délai dépassé (`context.DeadlineExceeded` du Go).
const CALL_DEADLINE: &str = "context deadline exceeded";
/// Issue d'une tentative de connexion.
#[derive(Default)]
struct SlotState {
@@ -251,15 +254,20 @@ impl Mcp {
return format!("[erreur] outil MCP inconnu ou serveur non connecté: {name}");
};
let cap = config::output_cap(config::ctx_window(&store.config().await));
let mut err = match self.call_once(&server, &tool, args, cap).await {
let servers = Self::load(store).await;
let timeout =
config::call_timeout_for(&servers.get(&server).cloned().unwrap_or_default(), args);
let mut err = match self.call_once(&server, &tool, args, cap, timeout).await {
Ok(r) => return r,
Err(e) => e,
};
let servers = Self::load(store).await;
if err == CALL_DEADLINE {
return config::call_deadline_error(timeout);
}
if let Some(cfg) = servers.get(&server).filter(|c| c.enabled) {
self.invalidate(&server).await;
self.ensure(&server, cfg, version).await;
match self.call_once(&server, &tool, args, cap).await {
match self.call_once(&server, &tool, args, cap, timeout).await {
Ok(r) => return r,
Err(e) => err = e,
}
@@ -273,6 +281,7 @@ impl Mcp {
tool: &str,
args: &Map<String, Value>,
cap: usize,
timeout: std::time::Duration,
) -> Result<String, String> {
let slot = lock(&self.slots).get(server).cloned();
let sess = slot
@@ -281,9 +290,9 @@ impl Mcp {
.and_then(|st| st.sess.clone())
.filter(|s| !s.is_closed())
.ok_or_else(|| format!("serveur {server} non connecté"))?;
let res = tokio::time::timeout(config::CALL_TIMEOUT, sess.call_tool(tool, args))
let res = tokio::time::timeout(timeout, sess.call_tool(tool, args))
.await
.map_err(|_| "context deadline exceeded".to_string())??;
.map_err(|_| CALL_DEADLINE.to_string())??;
let out = config::cap_output(config::flatten_content(&res), cap);
if res.get("isError").and_then(Value::as_bool) == Some(true) {
return Ok(format!("[l'outil a renvoyé une erreur]\n{out}"));
@@ -570,6 +579,7 @@ pub async fn serve(
.collect(),
enabled: d.opt_bool("enabled").unwrap_or(true),
disabled_tools: Vec::new(),
call_timeout_sec: 0,
};
let name = d.str("name");
let res = mcp