mirror of
https://github.com/R0m1k3/Loki.git
synced 2026-10-21 05:58:13 +02:00
Lot 42 : outils web de l'agent exécutés par la façade
Les outils web des tours de la façade (web_search, web_images, web_open,
web_read, web_grep) ne passent plus par /tool/exec du Go : src/webtools.rs
porte chat_internet_tools.go, chat_internet.go et chat_web_images.go. Le
cache des pages (10 min, clé MD5 des options comme le Go) est dans la
façade ; client Crawl4AI au corps près, réponses lues comme json.Unmarshal ;
résultats DuckDuckGo tirés du markdown (uddg, entités, extraits) ;
recherche d'images avec le jeton vqd et les cookies gardés par hôte ;
web_grep sur les regex du Go, erreurs au texte de regexp.
Le moteur intégré lit le HTML avec go-readability et html-to-markdown, que
rien en Rust ne reproduit au caractère près : la conversion d'une page et la
lecture des résultats restent au Go, derrière /internal/v1/web/fetch et
/internal/v1/web/search (sans état). web_screenshot reste au Go.
Le traducteur go_regex refuse la répétition d'une répétition et les comptes
au-delà de 1000, et rend littéral un « { » qui n'ouvre pas de répétition,
comme le Go (motifs retry_patterns compris).
Parité : journal de 74 appels tiré du Go (parity_web_tools_test.go →
tests/parity_web/outils.json : résultats, requêtes reçues par les faux
Crawl4AI et DuckDuckGo, pages demandées au moteur intégré), rejoué par
tests/webtools.rs ; un tour de la façade qui ouvre une page (tests/lot6.rs) ;
TestInternalMoteurWebIntegre côté Go.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016tcGfJWefW5kUGoC2wL8cv
This commit is contained in:
19 files changed
+4561
-7
No files matched your search
@@ -149,6 +149,12 @@ pub struct FakeGo {
|
||||
pub projsnaps: Mutex<std::collections::VecDeque<serde_json::Value>>,
|
||||
pub projsnap_refresh: Mutex<Option<serde_json::Value>>,
|
||||
pub projsnap_refreshes: Mutex<Vec<serde_json::Value>>,
|
||||
/// Moteur web intégré (lot 42) : réponses de /internal/v1/web/fetch par
|
||||
/// URL et de /internal/v1/web/search par « requête|limite » ; appels
|
||||
/// reçus (« fetch URL », « search requête|limite »).
|
||||
pub web_fetch: Mutex<HashMap<String, serde_json::Value>>,
|
||||
pub web_search: Mutex<HashMap<String, serde_json::Value>>,
|
||||
pub web_log: Mutex<Vec<String>>,
|
||||
}
|
||||
|
||||
impl FakeGo {
|
||||
@@ -505,6 +511,28 @@ async fn handle(st: Arc<FakeGo>, req: Request<Incoming>) -> Response<Full<Bytes>
|
||||
200,
|
||||
serde_json::json!({ "generating": *st.generating.lock().unwrap() }).to_string(),
|
||||
),
|
||||
"/internal/v1/web/fetch" | "/internal/v1/web/search" => {
|
||||
let fetch = path == "/internal/v1/web/fetch";
|
||||
let body = req.into_body().collect().await.unwrap().to_bytes();
|
||||
let v: serde_json::Value = serde_json::from_slice(&body).unwrap_or_default();
|
||||
let key = if fetch {
|
||||
v["url"].as_str().unwrap_or("").to_string()
|
||||
} else {
|
||||
format!("{}|{}", v["query"].as_str().unwrap_or(""), v["limit"])
|
||||
};
|
||||
st.web_log
|
||||
.lock()
|
||||
.unwrap()
|
||||
.push(format!("{} {key}", if fetch { "fetch" } else { "search" }));
|
||||
let map = if fetch { &st.web_fetch } else { &st.web_search };
|
||||
let out = map
|
||||
.lock()
|
||||
.unwrap()
|
||||
.get(&key)
|
||||
.cloned()
|
||||
.unwrap_or_else(|| serde_json::json!({ "error": format!("inconnu : {key}") }));
|
||||
resp(200, out.to_string())
|
||||
}
|
||||
"/internal/v1/web/reach/reset" => {
|
||||
*st.reach_resets.lock().unwrap() += 1;
|
||||
resp(204, "")
|
||||
|
||||
@@ -671,6 +671,7 @@ async fn tour_complet_par_le_go() {
|
||||
mcp: None,
|
||||
tasks: None,
|
||||
memory: None,
|
||||
web: Default::default(),
|
||||
go_version: Default::default(),
|
||||
});
|
||||
let cfg = std::collections::HashMap::new();
|
||||
@@ -2746,3 +2747,44 @@ async fn outil_memoire_par_la_facade() {
|
||||
.clone();
|
||||
assert_eq!(tool, serde_json::json!("[ok] page 'chat' créée"));
|
||||
}
|
||||
|
||||
/// Lot 42 : un outil web d'un tour de la façade s'exécute chez elle (cache
|
||||
/// des pages compris) ; seule la conversion du moteur intégré est demandée
|
||||
/// au Go, jamais /tool/exec.
|
||||
#[tokio::test]
|
||||
async fn outil_web_par_la_facade() {
|
||||
let call = sse(&[
|
||||
serde_json::json!({"choices":[{"delta":{"tool_calls":[{"index":0,"id":"w1","type":"function","function":{"name":"web_open","arguments":"{\"url\":\"https://exemple.test/doc\"}"}}]}}]}),
|
||||
serde_json::json!({"choices":[{"delta":{},"finish_reason":"tool_calls"}]}),
|
||||
]);
|
||||
let (url, seen) = fake_engine(vec![call, answer("lu")]).await;
|
||||
let (svc, go, _stop, _t) = service_with(Some(&url), |_| {}).await;
|
||||
go.set("config", "WEB_ENGINE", "go");
|
||||
go.web_fetch.lock().unwrap().insert(
|
||||
"https://exemple.test/doc".into(),
|
||||
serde_json::json!({"markdown": "# Doc\n\n## Partie\ntexte"}),
|
||||
);
|
||||
svc.send(serde_json::json!({"message": "lis la doc", "cid": "w"}))
|
||||
.await
|
||||
.unwrap();
|
||||
until_done(&svc, 1).await;
|
||||
assert!(go.tool_log.lock().unwrap().is_empty(), "{:?}", go.tool_log);
|
||||
assert_eq!(
|
||||
*go.web_log.lock().unwrap(),
|
||||
vec!["fetch https://exemple.test/doc".to_string()]
|
||||
);
|
||||
let seen = seen.lock().unwrap().clone();
|
||||
let tool = seen[1].1["messages"]
|
||||
.as_array()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.rfind(|m| m["role"] == "tool")
|
||||
.unwrap()["content"]
|
||||
.clone();
|
||||
assert_eq!(
|
||||
tool,
|
||||
serde_json::json!(
|
||||
"# Ouvert : https://exemple.test/doc\nTotal : 4 lignes, 23 B (23 caractères)\nEn cache 10 min. Utilise web_read ou web_grep pour lire.\n\n## Plan (n° de ligne des titres)\n```\n 1 | Doc\n 3 | Partie\n```"
|
||||
)
|
||||
);
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,291 @@
|
||||
//! Lot 42 : outils web de l'agent (`web_search`, `web_images`, `web_open`,
|
||||
//! `web_read`, `web_grep`) exécutés par la façade.
|
||||
//!
|
||||
//! Le journal tiré du Go (`internal/loki/parity_web_tools_test.go` →
|
||||
//! `tests/parity_web/outils.json`) est rejoué contre le port : mêmes
|
||||
//! réponses du faux Crawl4AI et du faux DuckDuckGo, ce que le moteur intégré
|
||||
//! du Go a tiré des pages servi par le faux Go ; chaque appel doit rendre le
|
||||
//! même résultat et faire les mêmes requêtes (corps envoyés à Crawl4AI,
|
||||
//! en-têtes et cookies envoyés à DuckDuckGo, pages et recherches demandées
|
||||
//! au Go).
|
||||
|
||||
mod common;
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::convert::Infallible;
|
||||
use std::sync::{Arc, Mutex};
|
||||
use std::time::Duration;
|
||||
|
||||
use bytes::Bytes;
|
||||
use common::fakego;
|
||||
use http_body_util::{BodyExt, Full};
|
||||
use hyper::service::service_fn;
|
||||
use hyper::{Request, Response};
|
||||
use hyper_util::rt::TokioIo;
|
||||
use loki_rs::store::Store;
|
||||
use loki_rs::webtools::Web;
|
||||
use serde_json::{Map, Value, json};
|
||||
use tokio::net::TcpListener;
|
||||
|
||||
fn journal() -> Value {
|
||||
serde_json::from_str(include_str!("parity_web/outils.json")).expect("journal")
|
||||
}
|
||||
|
||||
fn s(v: &Value) -> &str {
|
||||
v.as_str().unwrap_or("")
|
||||
}
|
||||
|
||||
type Log = Arc<Mutex<Vec<Value>>>;
|
||||
|
||||
/// Un petit serveur HTTP : `f(méthode, chemin?requête, en-têtes, corps)`
|
||||
/// rend (statut, en-têtes, corps).
|
||||
async fn serve<F>(f: F) -> String
|
||||
where
|
||||
F: Fn(String, String, hyper::HeaderMap, Vec<u8>) -> (u16, Vec<(String, String)>, String)
|
||||
+ Send
|
||||
+ Sync
|
||||
+ 'static,
|
||||
{
|
||||
let l = TcpListener::bind("127.0.0.1:0").await.unwrap();
|
||||
let addr = l.local_addr().unwrap();
|
||||
let f = Arc::new(f);
|
||||
tokio::spawn(async move {
|
||||
while let Ok((st, _)) = l.accept().await {
|
||||
let f = f.clone();
|
||||
tokio::spawn(async move {
|
||||
let svc = service_fn(move |req: Request<hyper::body::Incoming>| {
|
||||
let f = f.clone();
|
||||
async move {
|
||||
let method = req.method().to_string();
|
||||
let uri = req
|
||||
.uri()
|
||||
.path_and_query()
|
||||
.map(|p| p.to_string())
|
||||
.unwrap_or_default();
|
||||
let headers = req.headers().clone();
|
||||
let body = req.into_body().collect().await.unwrap().to_bytes().to_vec();
|
||||
let (code, hs, out) = f(method, uri, headers, body);
|
||||
let mut r = Response::new(Full::new(Bytes::from(out)));
|
||||
*r.status_mut() = hyper::StatusCode::from_u16(code).unwrap();
|
||||
for (k, v) in hs {
|
||||
r.headers_mut().insert(
|
||||
hyper::header::HeaderName::from_bytes(k.as_bytes()).unwrap(),
|
||||
v.parse().unwrap(),
|
||||
);
|
||||
}
|
||||
Ok::<_, Infallible>(r)
|
||||
}
|
||||
});
|
||||
let _ = hyper::server::conn::http1::Builder::new()
|
||||
.serve_connection(TokioIo::new(st), svc)
|
||||
.await;
|
||||
});
|
||||
}
|
||||
});
|
||||
format!("http://{addr}")
|
||||
}
|
||||
|
||||
fn header(h: &hyper::HeaderMap, k: &str) -> String {
|
||||
h.get(k)
|
||||
.and_then(|v| v.to_str().ok())
|
||||
.unwrap_or("")
|
||||
.to_string()
|
||||
}
|
||||
|
||||
/// Une requête reçue, champs vides omis (comme `omitempty`).
|
||||
fn req(fields: &[(&str, String)]) -> Value {
|
||||
let mut m = Map::new();
|
||||
for (k, v) in fields {
|
||||
if !v.is_empty() {
|
||||
m.insert((*k).into(), json!(v));
|
||||
}
|
||||
}
|
||||
Value::Object(m)
|
||||
}
|
||||
|
||||
/// Le faux Crawl4AI : la réponse rangée pour l'URL demandée (dans l'ordre,
|
||||
/// la dernière reste).
|
||||
async fn fake_crawl(fx: Value, log: Log) -> String {
|
||||
let served: Arc<Mutex<HashMap<String, usize>>> = Default::default();
|
||||
serve(move |method, uri, h, body| {
|
||||
let path = uri.split('?').next().unwrap_or("").to_string();
|
||||
log.lock().unwrap().push(req(&[
|
||||
("path", format!("{method} {path}")),
|
||||
("auth", header(&h, "authorization")),
|
||||
("ctype", header(&h, "content-type")),
|
||||
("body", String::from_utf8_lossy(&body).into_owned()),
|
||||
]));
|
||||
let v: Value = serde_json::from_slice(&body).unwrap_or_default();
|
||||
let target = s(&v["urls"][0]).to_string();
|
||||
let Some(list) = fx[&target].as_array().filter(|l| !l.is_empty()) else {
|
||||
return (404, vec![], format!("inconnu : {target}"));
|
||||
};
|
||||
let mut served = served.lock().unwrap();
|
||||
let n = served.entry(target).or_default();
|
||||
let r = &list[(*n).min(list.len() - 1)];
|
||||
*n += 1;
|
||||
(
|
||||
r["status"].as_u64().unwrap() as u16,
|
||||
vec![],
|
||||
s(&r["body"]).to_string(),
|
||||
)
|
||||
})
|
||||
.await
|
||||
}
|
||||
|
||||
/// Le faux DuckDuckGo des images.
|
||||
async fn fake_ddg(fx: Value, log: Log) -> String {
|
||||
serve(move |_, uri, h, _| {
|
||||
log.lock().unwrap().push(req(&[
|
||||
("path", uri.clone()),
|
||||
("referer", header(&h, "referer")),
|
||||
("accept", header(&h, "accept")),
|
||||
("lang", header(&h, "accept-language")),
|
||||
("ua", header(&h, "user-agent")),
|
||||
("cookie", header(&h, "cookie")),
|
||||
]));
|
||||
let (path, query) = uri.split_once('?').unwrap_or((&uri, ""));
|
||||
let q = url::form_urlencoded::parse(query.as_bytes())
|
||||
.find(|(k, _)| k == "q")
|
||||
.map(|(_, v)| v.into_owned())
|
||||
.unwrap_or_default();
|
||||
let f = &fx[&q];
|
||||
if path == "/i.js" {
|
||||
return (
|
||||
f["api_status"].as_u64().unwrap() as u16,
|
||||
vec![],
|
||||
s(&f["api"]).to_string(),
|
||||
);
|
||||
}
|
||||
let mut hs = vec![];
|
||||
if !s(&f["cookie"]).is_empty() {
|
||||
hs.push(("set-cookie".to_string(), s(&f["cookie"]).to_string()));
|
||||
}
|
||||
(
|
||||
f["page_status"].as_u64().unwrap() as u16,
|
||||
hs,
|
||||
s(&f["page"]).to_string(),
|
||||
)
|
||||
})
|
||||
.await
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn outils_web_comme_le_go() {
|
||||
let fx = journal();
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let (_, go, _stop) = fakego::start(tmp.path().to_path_buf()).await;
|
||||
let backend = go.backend.lock().unwrap().unwrap();
|
||||
let store = Store::new(backend, fakego::JETON.into());
|
||||
|
||||
let (crawl_log, img_log): (Log, Log) = Default::default();
|
||||
let crawl = fake_crawl(fx["crawl"].clone(), crawl_log.clone()).await;
|
||||
let ddg = fake_ddg(fx["images"].clone(), img_log.clone()).await;
|
||||
let dead = {
|
||||
let l = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
|
||||
l.local_addr().unwrap().to_string()
|
||||
};
|
||||
let pages = "http://pages.invalid".to_string();
|
||||
let unmask = |v: &str| {
|
||||
v.replace("{{CRAWL}}", &crawl)
|
||||
.replace("{{DDG}}", &ddg)
|
||||
.replace("{{PAGES}}", &pages)
|
||||
.replace("{{DEADADDR}}", &dead)
|
||||
.replace("{{DEAD}}", &format!("http://{dead}"))
|
||||
};
|
||||
let mask = |v: &str| {
|
||||
v.replace(&crawl, "{{CRAWL}}")
|
||||
.replace(&ddg, "{{DDG}}")
|
||||
.replace(&pages, "{{PAGES}}")
|
||||
.replace(&format!("http://{dead}"), "{{DEAD}}")
|
||||
.replace(&dead, "{{DEADADDR}}")
|
||||
};
|
||||
for (k, v) in fx["go_fetch"].as_object().unwrap() {
|
||||
let v: Value = serde_json::from_str(&unmask(&v.to_string())).unwrap();
|
||||
go.web_fetch.lock().unwrap().insert(unmask(k), v);
|
||||
}
|
||||
for (k, v) in fx["go_search"].as_object().unwrap() {
|
||||
go.web_search.lock().unwrap().insert(k.clone(), v.clone());
|
||||
}
|
||||
|
||||
let web = Web::with_ddg_base(&ddg);
|
||||
for c in fx["calls"].as_array().unwrap() {
|
||||
let name = s(&c["name"]);
|
||||
for (k, v) in c["setup"].as_object().into_iter().flatten() {
|
||||
let (bucket, v) = if k == "crawl_key" {
|
||||
("state", s(v).to_string())
|
||||
} else {
|
||||
("config", unmask(s(v)))
|
||||
};
|
||||
if v == "-" {
|
||||
go.del(bucket, k);
|
||||
} else {
|
||||
go.set(bucket, k, &v);
|
||||
}
|
||||
}
|
||||
if let Some(n) = c["advance_s"].as_u64() {
|
||||
web.advance(Duration::from_secs(n));
|
||||
}
|
||||
let args: Map<String, Value> =
|
||||
serde_json::from_str(&unmask(&c["args"].to_string())).unwrap();
|
||||
let got = web
|
||||
.exec(&store, s(&c["tool"]), &args)
|
||||
.await
|
||||
.expect("un outil web");
|
||||
assert_eq!(mask(&got), s(&c["result"]), "{name}");
|
||||
|
||||
let masked = |log: &Log| -> Value {
|
||||
let v = Value::Array(std::mem::take(&mut *log.lock().unwrap()));
|
||||
serde_json::from_str(&mask(&v.to_string())).unwrap()
|
||||
};
|
||||
assert_eq!(masked(&crawl_log), c["crawls"], "{name} : Crawl4AI");
|
||||
// reqwest pose « Accept: */* » quand la requête n'en a pas ; le
|
||||
// client du Go n'en pose aucun (sans effet sur DuckDuckGo).
|
||||
let mut imgs = masked(&img_log);
|
||||
for r in imgs.as_array_mut().into_iter().flatten() {
|
||||
if r["accept"] == "*/*" {
|
||||
r.as_object_mut().unwrap().remove("accept");
|
||||
}
|
||||
}
|
||||
assert_eq!(imgs, c["images"], "{name} : DuckDuckGo");
|
||||
let calls = std::mem::take(&mut *go.web_log.lock().unwrap());
|
||||
let fetched: Vec<String> = calls
|
||||
.iter()
|
||||
.filter_map(|l| l.strip_prefix("fetch "))
|
||||
.map(&mask)
|
||||
.collect();
|
||||
let searched: Vec<String> = calls
|
||||
.iter()
|
||||
.filter_map(|l| l.strip_prefix("search "))
|
||||
.map(|q| q.rsplit_once('|').map_or(q, |(q, _)| q).to_string())
|
||||
.collect();
|
||||
assert_eq!(json!(fetched), c["go_fetch"], "{name} : pages du Go");
|
||||
assert_eq!(json!(searched), c["go_search"], "{name} : recherches du Go");
|
||||
}
|
||||
}
|
||||
|
||||
/// Le Go injoignable : le moteur intégré (par défaut sans configuration)
|
||||
/// le dit ; un autre outil n'est pas servi ici.
|
||||
#[tokio::test]
|
||||
async fn moteur_integre_injoignable() {
|
||||
let dead = std::net::TcpListener::bind("127.0.0.1:0")
|
||||
.unwrap()
|
||||
.local_addr()
|
||||
.unwrap();
|
||||
let store = Store::new(dead, fakego::JETON.into());
|
||||
let web = Web::new();
|
||||
let mut args = Map::new();
|
||||
args.insert("url".into(), json!("https://exemple.test/"));
|
||||
let got = web.exec(&store, "web_open", &args).await.unwrap();
|
||||
assert!(
|
||||
got.starts_with("❌ moteur web intégré injoignable : "),
|
||||
"{got}"
|
||||
);
|
||||
args.insert("query".into(), json!("x"));
|
||||
let got = web.exec(&store, "web_search", &args).await.unwrap();
|
||||
assert!(
|
||||
got.starts_with("❌ Recherche échouée : moteur web intégré injoignable : "),
|
||||
"{got}"
|
||||
);
|
||||
assert_eq!(web.exec(&store, "web_screenshot", &args).await, None);
|
||||
}
|
||||
Reference in new issue
Block a user