mirror of
https://github.com/R0m1k3/Loki.git
synced 2026-10-16 19:59:56 +02:00
Lot 36 : appels d'outils MCP du tour de la façade par son pool
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
This commit is contained in:
8 files changed
+223
-11
No files matched your search
@@ -237,7 +237,7 @@ redémarrages (volume `/data`). Variables d'environnement du conteneur :
|
||||
| `LOKI_SERVE` | `rust` : la ligne de commande de llama-server est composée par la façade Rust (`loki-rs engine serve`) au lieu de `loki serve` ; Strata et l'optimiseur restent au Go (expérimental, migration en cours) | Go |
|
||||
| `LOKI_PRESETS` | `rust` : les presets (liste, éditeur et aperçus, suppression, placement auto, presets externes, bascule) sont tenus par la façade Rust ; l'application au moteur reste au Go (expérimental, migration en cours) | Go |
|
||||
| `LOKI_UPDATE` | `rust` : la mise à jour de Loki (`/api/update`, `/api/update/apply`) est servie par la façade Rust ; en conteneur, l'image se met à jour par Docker (expérimental, migration en cours) | Go |
|
||||
| `LOKI_MCP` | `rust` : les serveurs MCP — configuration, catalogue, état, pool de sessions stdio et HTTP — sont tenus par la façade Rust ; le Go lui demande les outils MCP de l'agent et leurs appels (expérimental, migration en cours) | Go |
|
||||
| `LOKI_MCP` | `rust` : les serveurs MCP — configuration, catalogue, état, pool de sessions stdio et HTTP — sont tenus par la façade Rust ; le Go lui demande les outils MCP de l'agent et leurs appels ; les tours de la façade appellent son pool directement (expérimental, migration en cours) | Go |
|
||||
| `LOKI_BACKENDS` | `rust` : les backends llama.cpp — statut, vérification git, binaires précompilés, bascule fast/opt, backends custom, GPU vus par un moteur — et le job des moteurs sont tenus par la façade Rust ; les jobs du Go (compilation, moteur de l'image, Strata) lui rapportent leurs étapes (expérimental, migration en cours) | Go |
|
||||
| `LOKI_STRATA` | `rust` (avec `LOKI_BACKENDS=rust`) : l'écran de Strata — machine, ce qui y tient, recommandation, modèles installés —, ses réglages et son installation sont tenus par la façade Rust ; le lancement reste au Go (expérimental, migration en cours) | Go |
|
||||
| `LOKI_TASKS` | `rust` : les tâches planifiées — liste, enregistrement, bascule, interrupteur maître, lancement, arrêt —, le planificateur et les tâches « script seul » sont tenus par la façade Rust ; une tâche IA reste exécutée par le Go, à la demande de la façade (expérimental, migration en cours) | Go |
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
# Lot 36 — Appels d'outils MCP sans détour par le Go
|
||||
|
||||
## Contexte
|
||||
|
||||
Depuis le lot 20, la façade tient le pool MCP (`LOKI_MCP=rust`) : sessions
|
||||
stdio et HTTP, registre des outils exposés. Le tour de la façade, lui,
|
||||
faisait encore exécuter un appel `mcp__*` par le Go
|
||||
(`/internal/v1/tool/exec`).
|
||||
|
||||
Le Go ne l'exécutait pas lui-même : `mcpCall` renvoyait l'appel à la façade
|
||||
(`/internal/facade/v1/mcp/call`). Chaque appel faisait donc deux
|
||||
allers-retours locaux pour revenir au même pool.
|
||||
|
||||
Pour un outil MCP, le Go n'ajoute rien au résultat (`toolStepExtras`) :
|
||||
|
||||
- ni capture ;
|
||||
- ni aperçu du navigateur ;
|
||||
- ni image relayée.
|
||||
|
||||
Seul le texte revient.
|
||||
|
||||
## Le lot
|
||||
|
||||
- `ChatShared` porte le pool (`mcp`), le même que celui des routes (créé une
|
||||
fois dans `main.rs`).
|
||||
- `GoTurnHost::exec_tool` : un nom `mcp__*`, pool tenu → `Mcp::call`
|
||||
directement. Le résultat est celui de la route interne :
|
||||
- texte aplati et coupé ;
|
||||
- « [l'outil a renvoyé une erreur] » ;
|
||||
- « [ok] (aucune sortie) » ;
|
||||
- une reconnexion si la session est morte, puis « [erreur MCP] … ».
|
||||
- Une reconnexion annonce la version du Go dans `clientInfo`, comme la route
|
||||
interne. Elle est lue une fois (`/internal/v1/auth`) et retenue.
|
||||
- Sans pool tenu (`LOKI_MCP` absent), rien ne change : l'appel part au Go,
|
||||
qui l'exécute avec son propre pool.
|
||||
|
||||
Le Go garde la route interne pour les tours qu'il exécute encore :
|
||||
|
||||
- sous-agents ;
|
||||
- tâches IA ;
|
||||
- discussion rendue au Go.
|
||||
|
||||
## Tests
|
||||
|
||||
`tests/lot6.rs` (`outil_mcp_par_le_pool_de_la_facade`) fait tourner un faux
|
||||
serveur MCP stdio. Un tour de la façade appelle :
|
||||
|
||||
- un outil qui répond ;
|
||||
- un outil dont le serveur meurt.
|
||||
|
||||
Le test vérifie :
|
||||
|
||||
- aucun appel au Go ;
|
||||
- le résultat de chaque outil tel que le modèle le reçoit ;
|
||||
- l'erreur après une reconnexion ;
|
||||
- la version du Go annoncée à cette reconnexion.
|
||||
@@ -14,10 +14,11 @@ import (
|
||||
// de la migration ; expérimental, le Go reste le défaut) : configuration,
|
||||
// catalogue, état et pool de sessions (rust/loki-rs/src/mcp).
|
||||
//
|
||||
// Un seul pool : celui de la façade. Le Go, qui exécute encore les outils de
|
||||
// l'agent, lui demande les outils MCP à annoncer au modèle et leurs appels
|
||||
// (/internal/facade/v1/mcp/*), quelle que soit la conversation qui tourne ;
|
||||
// son propre pool reste vide.
|
||||
// Un seul pool : celui de la façade. Le Go lui demande les outils MCP à
|
||||
// annoncer au modèle, et leurs appels pour les tours qu'il exécute encore
|
||||
// (sous-agents, tâches IA, discussion rendue au Go) via
|
||||
// /internal/facade/v1/mcp/* ; son propre pool reste vide. Les tours de la
|
||||
// façade appellent son pool sans passer par ici (lot 36).
|
||||
|
||||
const facadeMCPEnv = "LOKI_MCP"
|
||||
|
||||
|
||||
@@ -1303,6 +1303,8 @@ impl ChatShared {
|
||||
patterns: super::stream::RetryPatterns::builtin(),
|
||||
tpl: std::sync::Arc::new(super::tplprobe::TplProbe::new()),
|
||||
prewarm: Default::default(),
|
||||
mcp: None,
|
||||
go_version: Default::default(),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -3,7 +3,8 @@
|
||||
//! - 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) ; les autres par le Go (`/internal/v1/tool/exec`), avec
|
||||
//! tenu par le Go), outils MCP par le pool de la façade quand elle le tient
|
||||
//! (lot 36) ; 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 ;
|
||||
@@ -330,6 +331,32 @@ pub struct ChatShared {
|
||||
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>>,
|
||||
/// 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.
|
||||
@@ -615,6 +642,18 @@ impl TurnHost for GoTurnHost {
|
||||
}
|
||||
|
||||
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.
|
||||
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 {
|
||||
|
||||
@@ -262,8 +262,11 @@ fn serve() -> ExitCode {
|
||||
return ExitCode::FAILURE;
|
||||
}
|
||||
};
|
||||
// Un seul pool MCP : celui des routes, et celui des tours de la façade.
|
||||
let mcp = mcp_rust
|
||||
.then(|| std::sync::Arc::new(loki_rs::mcp::Mcp::new(engine_paths.home.clone())));
|
||||
let chat = if chat_rust {
|
||||
match start_chat(backend, &token, &engine_paths.home).await {
|
||||
match start_chat(backend, &token, &engine_paths.home, mcp.clone()).await {
|
||||
Ok(c) => Some(c),
|
||||
Err(e) => {
|
||||
eprintln!("[loki-rs] conversation : {e}");
|
||||
@@ -386,8 +389,7 @@ fn serve() -> ExitCode {
|
||||
settings: settings_rust,
|
||||
files,
|
||||
push: push_rust.then(|| std::sync::Arc::new(loki_rs::push::Push::new())),
|
||||
mem: mem_rust
|
||||
.then(|| std::sync::Arc::new(loki_rs::memory::Memory::new(mem_home.clone()))),
|
||||
mem: mem_rust.then(|| std::sync::Arc::new(loki_rs::memory::Memory::new(mem_home))),
|
||||
presets: presets_rust,
|
||||
engine_ctl,
|
||||
system: system_rust.then(|| std::sync::Arc::new(loki_rs::system::System::new())),
|
||||
@@ -399,7 +401,7 @@ fn serve() -> ExitCode {
|
||||
tasks,
|
||||
bench,
|
||||
tune,
|
||||
mcp: mcp_rust.then(|| std::sync::Arc::new(loki_rs::mcp::Mcp::new(mem_home))),
|
||||
mcp,
|
||||
};
|
||||
let res = proxy::serve(listener, cfg, shutdown_signal()).await;
|
||||
eprintln!("[loki-rs] arrêt demandé — arrêt du serveur Go");
|
||||
@@ -510,6 +512,7 @@ async fn start_chat(
|
||||
backend: SocketAddr,
|
||||
token: &str,
|
||||
home: &std::path::Path,
|
||||
mcp: Option<std::sync::Arc<loki_rs::mcp::Mcp>>,
|
||||
) -> Result<std::sync::Arc<loki_rs::chat::generate::ChatService>, String> {
|
||||
use loki_rs::chat::{conv::Conversation, generate::ChatService, host::ChatShared, sessions};
|
||||
let store = loki_rs::store::Store::new(backend, token.to_string());
|
||||
@@ -521,6 +524,7 @@ async fn start_chat(
|
||||
s.load().await?;
|
||||
let mut shared = ChatShared::for_sessions(&s, store.clone(), home.join("chatimg"));
|
||||
shared.patterns = loki_rs::chat::generate::retry_patterns(&store).await;
|
||||
shared.mcp = mcp;
|
||||
eprintln!("[loki-rs] conversation tenue par la façade (LOKI_CHAT=go pour la rendre au Go)");
|
||||
Ok(ChatService::new(
|
||||
std::sync::Arc::new(s),
|
||||
|
||||
@@ -1006,6 +1006,7 @@ pub async fn start_with(
|
||||
s.load().await.unwrap();
|
||||
let mut shared = ChatShared::for_sessions(&s, store.clone(), home.join("chatimg"));
|
||||
shared.patterns = loki_rs::chat::generate::retry_patterns(&store).await;
|
||||
shared.mcp = opts.mcp.clone();
|
||||
Some(ChatService::new(Arc::new(s), Arc::new(shared)))
|
||||
} else {
|
||||
None
|
||||
|
||||
+110
-1
@@ -667,6 +667,8 @@ async fn tour_complet_par_le_go() {
|
||||
patterns: RetryPatterns::builtin(),
|
||||
tpl: Arc::new(loki_rs::chat::tplprobe::TplProbe::new()),
|
||||
prewarm: Default::default(),
|
||||
mcp: None,
|
||||
go_version: Default::default(),
|
||||
});
|
||||
let cfg = std::collections::HashMap::new();
|
||||
let host = GoTurnHost {
|
||||
@@ -848,6 +850,19 @@ async fn service(
|
||||
Arc<fakego::FakeGo>,
|
||||
tokio::sync::oneshot::Sender<()>,
|
||||
tempfile::TempDir,
|
||||
) {
|
||||
service_with(engine_url, None).await
|
||||
}
|
||||
|
||||
/// [`service`], avec le pool MCP de la façade.
|
||||
async fn service_with(
|
||||
engine_url: Option<&str>,
|
||||
mcp: Option<Arc<loki_rs::mcp::Mcp>>,
|
||||
) -> (
|
||||
Arc<ChatService>,
|
||||
Arc<fakego::FakeGo>,
|
||||
tokio::sync::oneshot::Sender<()>,
|
||||
tempfile::TempDir,
|
||||
) {
|
||||
let (st, go, stop, t) = store().await;
|
||||
go.set(
|
||||
@@ -858,7 +873,8 @@ async fn service(
|
||||
go.set("state", "api_key", "cle-du-moteur");
|
||||
let sessions = Sessions::new(st.clone(), Conversation::new(), GoSessionHooks(st.clone()));
|
||||
sessions.load().await.unwrap();
|
||||
let shared = ChatShared::for_sessions(&sessions, st, t.path().join("chatimg"));
|
||||
let mut shared = ChatShared::for_sessions(&sessions, st, t.path().join("chatimg"));
|
||||
shared.mcp = mcp;
|
||||
(
|
||||
ChatService::new(Arc::new(sessions), Arc::new(shared)),
|
||||
go,
|
||||
@@ -2384,3 +2400,96 @@ async fn bloc_projet_rafraichi_en_plein_tour() {
|
||||
serde_json::from_value(proj_snap("vivant", 0)).unwrap()
|
||||
);
|
||||
}
|
||||
|
||||
/// Lot 36 : un outil MCP appelé par un tour de la façade passe par son pool,
|
||||
/// pas par le Go ; une reconnexion annonce la version du Go.
|
||||
#[cfg(unix)]
|
||||
#[tokio::test]
|
||||
async fn outil_mcp_par_le_pool_de_la_facade() {
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
let call = |id: &str, name: &str| {
|
||||
sse(&[
|
||||
serde_json::json!({"choices":[{"delta":{"tool_calls":[{"index":0,"id":id,"type":"function","function":{"name":name,"arguments":"{\"q\":1}"}}]}}]}),
|
||||
serde_json::json!({"choices":[{"delta":{},"finish_reason":"tool_calls"}]}),
|
||||
])
|
||||
};
|
||||
let (url, seen) = fake_engine(vec![
|
||||
call("m1", "mcp__sh__date"),
|
||||
call("m2", "mcp__sh__crash"),
|
||||
answer("il est midi"),
|
||||
])
|
||||
.await;
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let bin = dir.path().join("faux-mcp");
|
||||
std::fs::write(
|
||||
&bin,
|
||||
r#"#!/bin/sh
|
||||
while IFS= read -r line; do
|
||||
id=$(printf '%s' "$line" | sed -n 's/.*"id":\([0-9]*\).*/\1/p')
|
||||
case "$line" in
|
||||
*'"method":"initialize"'*)
|
||||
printf '%s\n' "$line" >> "$TRACE"
|
||||
printf '{"jsonrpc":"2.0","id":%s,"result":{"protocolVersion":"2025-06-18","capabilities":{},"serverInfo":{"name":"sh","version":"1"}}}\n' "$id" ;;
|
||||
*'"method":"tools/list"'*)
|
||||
printf '{"jsonrpc":"2.0","id":%s,"result":{"tools":[{"name":"date","inputSchema":{"type":"object"}},{"name":"crash","inputSchema":{"type":"object"}}]}}\n' "$id" ;;
|
||||
*'"name":"crash"'*) exit 3 ;;
|
||||
*'"method":"tools/call"'*)
|
||||
printf '{"jsonrpc":"2.0","id":%s,"result":{"content":[{"type":"text","text":"midi"}]}}\n' "$id" ;;
|
||||
esac
|
||||
done
|
||||
"#,
|
||||
)
|
||||
.unwrap();
|
||||
std::fs::set_permissions(&bin, std::fs::Permissions::from_mode(0o755)).unwrap();
|
||||
let trace = dir.path().join("trace");
|
||||
let pool = Arc::new(loki_rs::mcp::Mcp::new(dir.path().to_path_buf()));
|
||||
let (svc, go, _stop, _t) = service_with(Some(&url), Some(pool.clone())).await;
|
||||
let mut servers = loki_rs::mcp::config::Servers::new();
|
||||
servers.insert(
|
||||
"sh".into(),
|
||||
loki_rs::mcp::config::ServerConfig {
|
||||
command: bin.to_string_lossy().into_owned(),
|
||||
env: [("TRACE".to_string(), trace.to_string_lossy().into_owned())].into(),
|
||||
..Default::default()
|
||||
},
|
||||
);
|
||||
go.set("state", "mcp", &loki_rs::gojson::marshal(&servers));
|
||||
// Les outils annoncés au modèle (ce que le Go demande à la façade) :
|
||||
// le registre du pool est rempli.
|
||||
let tools = pool.tools(&svc.shared.store, "annonce").await;
|
||||
assert_eq!(tools.len(), 2, "{tools:?}");
|
||||
svc.send(serde_json::json!({"message": "quelle heure ?", "cid": "m"}))
|
||||
.await
|
||||
.unwrap();
|
||||
until_done(&svc, 1).await;
|
||||
// Rien par le Go.
|
||||
assert!(go.tool_log.lock().unwrap().is_empty(), "{:?}", go.tool_log);
|
||||
let seen = seen.lock().unwrap().clone();
|
||||
assert_eq!(seen.len(), 3);
|
||||
let tool_msg = |i: usize| -> String {
|
||||
seen[i].1["messages"]
|
||||
.as_array()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.rfind(|m| m["role"] == "tool")
|
||||
.map(|m| m["content"].as_str().unwrap_or("").to_string())
|
||||
.unwrap_or_default()
|
||||
};
|
||||
assert_eq!(tool_msg(1), "midi");
|
||||
let crashed = tool_msg(2);
|
||||
assert!(
|
||||
crashed.starts_with("[erreur MCP] calling \"tools/call\""),
|
||||
"{crashed}"
|
||||
);
|
||||
// Première connexion : la version donnée à l'annonce ; reconnexion
|
||||
// après la chute : celle du Go.
|
||||
let inits: Vec<String> = std::fs::read_to_string(&trace)
|
||||
.unwrap()
|
||||
.lines()
|
||||
.map(String::from)
|
||||
.collect();
|
||||
assert_eq!(inits.len(), 2, "{inits:?}");
|
||||
assert!(inits[0].contains(r#""version":"annonce""#), "{inits:?}");
|
||||
assert!(inits[1].contains(r#""version":"0.15.0""#), "{inits:?}");
|
||||
pool.close_all().await;
|
||||
}
|
||||
Reference in new issue
Block a user