mirror of
https://github.com/R0m1k3/Loki.git
synced 2026-10-20 13:38:11 +02:00
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:
24 files changed
+1104
-112
No files matched your search
@@ -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 !"));
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in new issue
Block a user