mirror of
https://github.com/R0m1k3/Loki.git
synced 2026-10-18 12:46:56 +02:00
Lot 47 : outil subagent du mode Code exécuté par la façade
Le builder délègue à un rôle en lecture seule (explorer, code-reviewer, planner) sans passer par /tool/exec : src/chat/subagent.rs porte toolSubagent. Mêmes refus avant tout appel au moteur (rôle inconnu, verifier compris, tâche vide, récursion), contexte isolé servi par le Go (POST /internal/v1/turn/subagent : prompt du rôle + briefing machine, outils, capacités ; subagentView et subagentFraming partagés), tour imbriqué run_turn avec l'endpoint, le raisonnement, l'écho et l'arrêt du tour qui délègue, température 0,6, travail annexe (second slot, effacé à la fin), télémétrie à part. Seul le texte écrit après le dernier outil revient, rogné, préfixé du rôle, tronqué à 6000 runes. Le tour du rôle est porté par SubHost, qui délègue tout à son GoTurnHost sauf un nouvel appel à subagent ; les outils de la façade passent dans GoTurnHost::exec_tool_base, que seul le tour principal complète de subagent (le type du tour imbriqué ne se contient pas lui-même). Parité : journal tiré du Go contre un faux moteur scripté (parity_subagent_test.go → tests/parity_subagent/subagent.json : refus, réponse directe, rapport après deux outils, texte vide relancé, rapport tronqué, cadrage du planner, moteur qui refuse ; requêtes du rôle), rejoué par un tour complet de la façade (tests/lot6.rs) ; TestSubagentVueInterne 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:
16 files changed
+1846
-73
No files matched your search
@@ -1018,12 +1018,19 @@ impl ChatService {
|
||||
queued: Box::new(move || me.drain_queued(epoch)),
|
||||
deltas: {
|
||||
let me = self.clone();
|
||||
Box::new(move |d| me.push_delta(d))
|
||||
Arc::new(move |d| me.push_delta(d))
|
||||
},
|
||||
perf_kind: super::perf::KIND_MAIN,
|
||||
side_slot: Default::default(),
|
||||
projsnap: snap_turn,
|
||||
snap_refreshed: Default::default(),
|
||||
sub: Some(super::subagent::SubTurn {
|
||||
ep: ep.clone(),
|
||||
reasoning: reasoning.clone(),
|
||||
echo: echo.clone(),
|
||||
patterns: self.shared.patterns.clone(),
|
||||
cancel: cancel.clone(),
|
||||
}),
|
||||
};
|
||||
|
||||
// Chaque événement devient un delta du journal.
|
||||
|
||||
@@ -415,7 +415,7 @@ pub struct GoTurnHost {
|
||||
pub queued: Box<dyn Fn() -> Vec<Message> + Send + Sync>,
|
||||
/// Un événement pour l'écran, hors du flux du tour (la liste des
|
||||
/// critères après l'outil `criteria`).
|
||||
pub deltas: Box<dyn Fn(super::journal::Delta) + Send + Sync>,
|
||||
pub deltas: Arc<dyn Fn(super::journal::Delta) + Send + Sync>,
|
||||
/// Nature des complétions pour la télémétrie : `main` (tour de la
|
||||
/// discussion), `verify` (passe de vérification du mode Code).
|
||||
pub perf_kind: &'static str,
|
||||
@@ -426,6 +426,9 @@ pub struct GoTurnHost {
|
||||
/// l'instantané posé par un rafraîchissement en cours de tour.
|
||||
pub projsnap: Option<SnapTurn>,
|
||||
pub snap_refreshed: Mutex<Option<ProjSnapshot>>,
|
||||
/// Ce que le tour prête à l'outil `subagent` (lot 47) ; `None` : l'outil
|
||||
/// passe par le Go.
|
||||
pub sub: Option<super::subagent::SubTurn>,
|
||||
}
|
||||
|
||||
/// PROJ_SNAPSHOT (lot 34) : ce qu'un tour avec la clé garde pour rafraîchir
|
||||
@@ -614,63 +617,15 @@ pub fn see_image_message(label: &str, img: Value) -> Message {
|
||||
}
|
||||
}
|
||||
|
||||
impl TurnHost for GoTurnHost {
|
||||
async fn send(
|
||||
impl GoTurnHost {
|
||||
/// Les outils exécutés par la façade, sauf `subagent` ; le reste par le
|
||||
/// Go (`/tool/exec`). Aussi celui des tours d'un sous-agent.
|
||||
pub(crate) async fn exec_tool_base(
|
||||
&self,
|
||||
ep: &ChatEndpoint,
|
||||
body: Vec<u8>,
|
||||
keep_prewarm: bool,
|
||||
) -> Result<Reply, SendError> {
|
||||
// Moteur local : requête comptée en vol jusqu'à la fin de lecture du
|
||||
// flux (le ticket voyage avec le corps). Rien pour une API externe.
|
||||
// Un préchauffage qu'elle ne prolonge pas est coupé tout de suite (le
|
||||
// Go l'annulerait aussi, un aller-retour plus tard).
|
||||
let gate = if ep.external {
|
||||
None
|
||||
} else {
|
||||
if !keep_prewarm {
|
||||
self.shared.prewarm.stop();
|
||||
}
|
||||
self.gate_begin(keep_prewarm).await
|
||||
};
|
||||
match self.shared.http.post(&ep.url, &ep.key, body).await {
|
||||
Ok(resp) => {
|
||||
let status = resp.status().as_u16();
|
||||
if status == 200
|
||||
&& let Some(g) = &gate
|
||||
{
|
||||
// Le slot porte désormais cette requête (engineServed,
|
||||
// engineMarkMain pour une étape de la discussion).
|
||||
let _ = self
|
||||
.shared
|
||||
.store
|
||||
.internal_json(
|
||||
hyper::Method::POST,
|
||||
"/internal/v1/oai/served",
|
||||
Some(&json!({ "ticket": g.ticket })),
|
||||
)
|
||||
.await;
|
||||
}
|
||||
let body = resp.bytes_stream().map(move |r| {
|
||||
let _keep = &gate;
|
||||
r.map_err(|e| e.to_string())
|
||||
});
|
||||
Ok(Reply {
|
||||
status,
|
||||
body: Box::pin(body),
|
||||
})
|
||||
}
|
||||
Err(e) => {
|
||||
let kind = classify(&e);
|
||||
Err(SendError {
|
||||
kind,
|
||||
message: friendly_error(kind, &e.to_string(), self.port, ""),
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn exec_tool(&self, name: &str, args: &Map<String, Value>, label: &str) -> ToolOutcome {
|
||||
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.
|
||||
@@ -907,6 +862,77 @@ impl TurnHost for GoTurnHost {
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl TurnHost for GoTurnHost {
|
||||
async fn send(
|
||||
&self,
|
||||
ep: &ChatEndpoint,
|
||||
body: Vec<u8>,
|
||||
keep_prewarm: bool,
|
||||
) -> Result<Reply, SendError> {
|
||||
// Moteur local : requête comptée en vol jusqu'à la fin de lecture du
|
||||
// flux (le ticket voyage avec le corps). Rien pour une API externe.
|
||||
// Un préchauffage qu'elle ne prolonge pas est coupé tout de suite (le
|
||||
// Go l'annulerait aussi, un aller-retour plus tard).
|
||||
let gate = if ep.external {
|
||||
None
|
||||
} else {
|
||||
if !keep_prewarm {
|
||||
self.shared.prewarm.stop();
|
||||
}
|
||||
self.gate_begin(keep_prewarm).await
|
||||
};
|
||||
match self.shared.http.post(&ep.url, &ep.key, body).await {
|
||||
Ok(resp) => {
|
||||
let status = resp.status().as_u16();
|
||||
if status == 200
|
||||
&& let Some(g) = &gate
|
||||
{
|
||||
// Le slot porte désormais cette requête (engineServed,
|
||||
// engineMarkMain pour une étape de la discussion).
|
||||
let _ = self
|
||||
.shared
|
||||
.store
|
||||
.internal_json(
|
||||
hyper::Method::POST,
|
||||
"/internal/v1/oai/served",
|
||||
Some(&json!({ "ticket": g.ticket })),
|
||||
)
|
||||
.await;
|
||||
}
|
||||
let body = resp.bytes_stream().map(move |r| {
|
||||
let _keep = &gate;
|
||||
r.map_err(|e| e.to_string())
|
||||
});
|
||||
Ok(Reply {
|
||||
status,
|
||||
body: Box::pin(body),
|
||||
})
|
||||
}
|
||||
Err(e) => {
|
||||
let kind = classify(&e);
|
||||
Err(SendError {
|
||||
kind,
|
||||
message: friendly_error(kind, &e.to_string(), self.port, ""),
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn exec_tool(&self, name: &str, args: &Map<String, Value>, label: &str) -> ToolOutcome {
|
||||
// Sous-agent du mode Code (lot 47) : un tour imbriqué, sur un
|
||||
// contexte isolé, dont seul le rapport revient.
|
||||
if name == "subagent"
|
||||
&& let Some(sub) = &self.sub
|
||||
{
|
||||
return ToolOutcome {
|
||||
result: super::subagent::run(self, sub, args).await,
|
||||
..Default::default()
|
||||
};
|
||||
}
|
||||
self.exec_tool_base(name, args, label).await
|
||||
}
|
||||
|
||||
fn save_tool_result(&self, result: &str) -> String {
|
||||
let id = tool_res_id(&self.conv_id);
|
||||
|
||||
@@ -37,6 +37,7 @@ pub mod persist;
|
||||
pub mod prewarm;
|
||||
pub mod sessions;
|
||||
pub mod stream;
|
||||
pub mod subagent;
|
||||
pub mod tplprobe;
|
||||
pub mod turn;
|
||||
pub mod types;
|
||||
|
||||
@@ -18,6 +18,8 @@ use crate::store::{BK_STATE, Store};
|
||||
/// Nature d'une complétion.
|
||||
pub const KIND_MAIN: &str = "main";
|
||||
pub const KIND_VERIFY: &str = "verify";
|
||||
/// Sous-agent du mode Code (`perfSubagent`).
|
||||
pub const KIND_SUBAGENT: &str = "subagent";
|
||||
pub const KIND_COMPACT: &str = "compact";
|
||||
pub const KIND_PREWARM: &str = "prewarm";
|
||||
|
||||
|
||||
@@ -0,0 +1,309 @@
|
||||
//! L'outil `subagent` du mode Code (lot 47) — port de `toolSubagent`
|
||||
//! (`code_subagent.go`).
|
||||
//!
|
||||
//! Le builder délègue une question bornée à un rôle en lecture seule
|
||||
//! (explorer, code-reviewer, planner) qui travaille dans SON PROPRE contexte :
|
||||
//! le prompt du rôle, le briefing machine et la tâche, rien de l'historique.
|
||||
//! Sa trace n'est ni rangée ni affichée : seul son texte final revient, comme
|
||||
//! résultat d'outil. Le contexte du rôle (prompt système, outils, capacités)
|
||||
//! vient du Go (`/internal/v1/turn/subagent`) ; le tour imbriqué tourne ici,
|
||||
//! comme la passe de vérification : un travail annexe (`engineSideJob`).
|
||||
//!
|
||||
//! Le tour du rôle est porté par [`SubHost`], qui délègue tout à son
|
||||
//! [`GoTurnHost`] sauf un nouvel appel à `subagent` : un sous-agent ne
|
||||
//! délègue pas à son tour (et le type du tour imbriqué ne se contient pas
|
||||
//! lui-même).
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use serde_json::{Map, Value, json};
|
||||
|
||||
use super::compact::CompactConfig;
|
||||
use super::host::{GoTurnHost, TurnCompact};
|
||||
use super::stream::{CacheHintFn, PreflightFn, RetryPatterns};
|
||||
use super::turn::{
|
||||
Cancel, CompactPhase, PerfMarks, PerfRec, Reply, SendError, ToolOutcome, TurnConfig, TurnHost,
|
||||
run_turn,
|
||||
};
|
||||
use super::types::{Message, StreamEvent, Tool};
|
||||
use super::wire::{ChatEndpoint, EchoPolicy, TurnReasoning};
|
||||
|
||||
/// `subagentRoles` : les rôles délégables (`verifier` n'en est pas : seule
|
||||
/// la boucle du contrat le lance).
|
||||
pub const ROLES: [&str; 3] = ["explorer", "code-reviewer", "planner"];
|
||||
/// `subagentTemperature` (le TEMP du preset, s'il est posé, l'emporte).
|
||||
pub const TEMPERATURE: f64 = 0.6;
|
||||
/// `subagentMaxOutput` : un rapport, pas un vidage de fichiers.
|
||||
pub const MAX_OUTPUT: usize = 6000;
|
||||
|
||||
/// Ce que le tour du builder prête à ses sous-agents : l'endpoint, le
|
||||
/// raisonnement, l'écho, les motifs de relance et l'arrêt du tour.
|
||||
#[derive(Clone)]
|
||||
pub struct SubTurn {
|
||||
pub ep: ChatEndpoint,
|
||||
pub reasoning: TurnReasoning,
|
||||
pub echo: EchoPolicy,
|
||||
pub patterns: Arc<RetryPatterns>,
|
||||
pub cancel: Cancel,
|
||||
}
|
||||
|
||||
/// `subagentFraming` : le cadrage ajouté à la tâche — l'interlocuteur du rôle
|
||||
/// est le builder, pas un humain.
|
||||
pub fn framing(role: &str) -> &'static str {
|
||||
if role == "planner" {
|
||||
"\n\nYou are a delegated pass: nobody will answer you. Record the acceptance criteria with the criteria tool, then give the ordered steps. Do not ask for confirmation."
|
||||
} else {
|
||||
"\n\nAnswer with your findings only: what you found and where (file:line). No preamble."
|
||||
}
|
||||
}
|
||||
|
||||
/// Les vérifications de `toolSubagent` avant tout appel au moteur : rôle,
|
||||
/// tâche, puis le garde-fou anti-récursion (`parent_role` : le rôle du tour
|
||||
/// qui délègue, vide ou `builder` pour le fil principal).
|
||||
pub fn precheck(args: &Map<String, Value>, parent_role: &str) -> Result<(String, String), String> {
|
||||
let s = |k: &str| {
|
||||
args.get(k)
|
||||
.and_then(Value::as_str)
|
||||
.unwrap_or("")
|
||||
.trim()
|
||||
.to_string()
|
||||
};
|
||||
let (role, task) = (s("role"), s("task"));
|
||||
if !ROLES.contains(&role.as_str()) {
|
||||
return Err(format!(
|
||||
"[erreur] rôle inconnu : {role} (attendu : {})",
|
||||
ROLES.join(", ")
|
||||
));
|
||||
}
|
||||
if task.is_empty() {
|
||||
return Err("[erreur] tâche vide — décris ce que le rôle doit chercher ou relire".into());
|
||||
}
|
||||
if !parent_role.is_empty() && parent_role != "builder" {
|
||||
return Err(
|
||||
"[erreur] un sous-agent ne peut pas en appeler un autre — réponds avec ce que tu as"
|
||||
.into(),
|
||||
);
|
||||
}
|
||||
Ok((role, task))
|
||||
}
|
||||
|
||||
/// Le rapport rendu au builder : texte écrit après le dernier outil, borné.
|
||||
pub fn report(role: &str, content: &str, err: Option<&str>) -> String {
|
||||
if let Some(e) = err {
|
||||
return format!("[erreur] sous-agent {role} : {e}");
|
||||
}
|
||||
let res = content.trim();
|
||||
if res.is_empty() {
|
||||
return format!("[erreur] le sous-agent {role} n'a rien répondu");
|
||||
}
|
||||
let res = if res.chars().count() > MAX_OUTPUT {
|
||||
format!(
|
||||
"{}\n…[tronqué]",
|
||||
res.chars().take(MAX_OUTPUT).collect::<String>()
|
||||
)
|
||||
} else {
|
||||
res.to_string()
|
||||
};
|
||||
format!("[{role}]\n{res}")
|
||||
}
|
||||
|
||||
fn caps_role(caps: &Value) -> &str {
|
||||
caps.get("Role").and_then(Value::as_str).unwrap_or("")
|
||||
}
|
||||
|
||||
/// `toolSubagent` : délègue au rôle et rend son rapport.
|
||||
pub async fn run(parent: &GoTurnHost, sub: &SubTurn, args: &Map<String, Value>) -> String {
|
||||
let (role, task) = match precheck(args, caps_role(&parent.caps)) {
|
||||
Ok(rt) => rt,
|
||||
Err(e) => return e,
|
||||
};
|
||||
let store = &parent.shared.store;
|
||||
let view = match store
|
||||
.internal_post("/internal/v1/turn/subagent", &json!({ "role": role }))
|
||||
.await
|
||||
{
|
||||
Ok((s, v)) if s.as_u16() == 200 => v,
|
||||
Ok((_, v)) => {
|
||||
let e = v["error"].as_str().unwrap_or("vue du sous-agent refusée");
|
||||
return report(&role, "", Some(e));
|
||||
}
|
||||
Err(e) => return report(&role, "", Some(&e)),
|
||||
};
|
||||
let tools: Vec<Tool> = serde_json::from_value(view["tools"].clone()).unwrap_or_default();
|
||||
let caps = view["caps"].clone();
|
||||
let msgs = vec![
|
||||
Message {
|
||||
role: "system".into(),
|
||||
content: Some(view["system"].clone()),
|
||||
..Default::default()
|
||||
},
|
||||
Message {
|
||||
role: "user".into(),
|
||||
content: Some(Value::String(format!("{task}{}", framing(&role)))),
|
||||
..Default::default()
|
||||
},
|
||||
];
|
||||
let compact: CompactConfig = parent.compact.cfg.clone();
|
||||
let tc = TurnConfig {
|
||||
cfg: parent.cfg.clone(),
|
||||
ep: sub.ep.clone(),
|
||||
tools: tools.clone(),
|
||||
temperature: TEMPERATURE,
|
||||
code: caps.get("Code").and_then(Value::as_bool) == Some(true),
|
||||
reasoning: sub.reasoning.clone(),
|
||||
echo: sub.echo.clone(),
|
||||
compact: compact.clone(),
|
||||
patterns: sub.patterns.clone(),
|
||||
};
|
||||
let host = SubHost(GoTurnHost {
|
||||
shared: parent.shared.clone(),
|
||||
conv_id: parent.conv_id.clone(),
|
||||
caps,
|
||||
cfg: parent.cfg.clone(),
|
||||
port: parent.port,
|
||||
summarizer: parent.summarizer.clone(),
|
||||
compact: TurnCompact {
|
||||
cfg: compact,
|
||||
caps: parent.compact.caps,
|
||||
criteria: String::new(),
|
||||
main_conv: parent.compact.main_conv.clone(),
|
||||
tools,
|
||||
effort: sub.reasoning.effort.clone(),
|
||||
kwargs: sub.reasoning.kwargs.clone().unwrap_or_default(),
|
||||
},
|
||||
queued: Box::new(Vec::new),
|
||||
// Les critères posés par le planner s'affichent comme ceux du fil.
|
||||
deltas: parent.deltas.clone(),
|
||||
perf_kind: super::perf::KIND_SUBAGENT,
|
||||
side_slot: Default::default(),
|
||||
projsnap: None,
|
||||
snap_refreshed: Default::default(),
|
||||
sub: None,
|
||||
});
|
||||
// Le rôle occupe le slot de la discussion du builder : effacé à la fin
|
||||
// pour que le builder reparte du cache RAM (pas entre ses lectures).
|
||||
let side = super::verify::side_job_begin(store).await;
|
||||
let mut out = String::new();
|
||||
let mut cb = |ev: StreamEvent| -> bool {
|
||||
match ev {
|
||||
// Seul le texte écrit APRÈS le dernier outil est le rapport.
|
||||
StreamEvent::ToolUsed(_) => out.clear(),
|
||||
StreamEvent::Content(c) => out.push_str(&c),
|
||||
_ => {}
|
||||
}
|
||||
true
|
||||
};
|
||||
let r = run_turn(&host, &tc, msgs, &sub.cancel, &mut cb).await;
|
||||
super::verify::side_job_end(store, side).await;
|
||||
report(&role, &out, r.err.as_deref())
|
||||
}
|
||||
|
||||
/// Le [`TurnHost`] du tour d'un rôle délégué : celui de la façade, sans
|
||||
/// `subagent`.
|
||||
pub struct SubHost(pub GoTurnHost);
|
||||
|
||||
impl TurnHost for SubHost {
|
||||
async fn send(
|
||||
&self,
|
||||
ep: &ChatEndpoint,
|
||||
body: Vec<u8>,
|
||||
keep_prewarm: bool,
|
||||
) -> Result<Reply, SendError> {
|
||||
self.0.send(ep, body, keep_prewarm).await
|
||||
}
|
||||
|
||||
async fn project_refresh(&self, msgs: Vec<Message>) -> Vec<Message> {
|
||||
self.0.project_refresh(msgs).await
|
||||
}
|
||||
|
||||
fn prewarm_keeps(&self, prefix: &[u64], payload: &Value) -> bool {
|
||||
self.0.prewarm_keeps(prefix, payload)
|
||||
}
|
||||
|
||||
async fn exec_tool(&self, name: &str, args: &Map<String, Value>, label: &str) -> ToolOutcome {
|
||||
if name == "subagent" {
|
||||
let result = match precheck(args, caps_role(&self.0.caps)) {
|
||||
Err(e) => e,
|
||||
// Un rôle sans `Role` : ne peut pas arriver (le Go le pose).
|
||||
Ok(_) => "[erreur] un sous-agent ne peut pas en appeler un autre — réponds avec ce que tu as".into(),
|
||||
};
|
||||
return ToolOutcome {
|
||||
result,
|
||||
..Default::default()
|
||||
};
|
||||
}
|
||||
self.0.exec_tool_base(name, args, label).await
|
||||
}
|
||||
|
||||
fn save_tool_result(&self, result: &str) -> String {
|
||||
self.0.save_tool_result(result)
|
||||
}
|
||||
|
||||
async fn compact(&self, phase: CompactPhase, msgs: &[Message]) -> Option<Vec<Message>> {
|
||||
self.0.compact(phase, msgs).await
|
||||
}
|
||||
|
||||
fn compact_refused(&self, used: i64, msgs: &[Message]) -> bool {
|
||||
self.0.compact_refused(used, msgs)
|
||||
}
|
||||
|
||||
fn drain_queued(&self) -> Vec<Message> {
|
||||
self.0.drain_queued()
|
||||
}
|
||||
|
||||
fn completed(&self, tools: &[Tool], kwargs: Option<&Map<String, Value>>, effort: &str) {
|
||||
self.0.completed(tools, kwargs, effort)
|
||||
}
|
||||
|
||||
fn prefix_unstable(
|
||||
&self,
|
||||
tools: &[Tool],
|
||||
kwargs: Option<&Map<String, Value>>,
|
||||
effort: &str,
|
||||
) -> bool {
|
||||
self.0.prefix_unstable(tools, kwargs, effort)
|
||||
}
|
||||
|
||||
async fn prepare_images(&self, msgs: &[Message]) {
|
||||
self.0.prepare_images(msgs).await
|
||||
}
|
||||
|
||||
fn image_data_url(&self, r: &str) -> Option<String> {
|
||||
self.0.image_data_url(r)
|
||||
}
|
||||
|
||||
fn effort_remember(&self, want: &str, got: &str) {
|
||||
self.0.effort_remember(want, got)
|
||||
}
|
||||
|
||||
fn echo_refuse(&self, model: &str) {
|
||||
self.0.echo_refuse(model)
|
||||
}
|
||||
|
||||
fn cache_hint(&self) -> Option<CacheHintFn> {
|
||||
self.0.cache_hint()
|
||||
}
|
||||
|
||||
fn preflight(&self) -> Option<PreflightFn> {
|
||||
self.0.preflight()
|
||||
}
|
||||
|
||||
async fn keep_images(&self) -> Option<i64> {
|
||||
self.0.keep_images().await
|
||||
}
|
||||
|
||||
async fn keep_image(&self, m: &Message) -> Option<(Message, i64)> {
|
||||
self.0.keep_image(m).await
|
||||
}
|
||||
|
||||
async fn engine_slot(&self, ep: &ChatEndpoint) -> Option<u8> {
|
||||
self.0.engine_slot(ep).await
|
||||
}
|
||||
|
||||
async fn perf_record(&self, rec: PerfRec) -> PerfMarks {
|
||||
self.0.perf_record(rec).await
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
@@ -0,0 +1,65 @@
|
||||
//! Tests unitaires du sous-agent (lot 47) ; la parité avec le Go est dans
|
||||
//! `tests/lot6.rs` (`subagent_comme_le_go`).
|
||||
|
||||
use super::*;
|
||||
|
||||
fn args(v: Value) -> Map<String, Value> {
|
||||
serde_json::from_value(v).unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn refus_avant_le_moteur() {
|
||||
let e = |v, p| precheck(&args(v), p).unwrap_err();
|
||||
assert!(
|
||||
e(json!({"role": "verifier", "task": "x"}), "")
|
||||
.starts_with("[erreur] rôle inconnu : verifier")
|
||||
);
|
||||
assert!(e(json!({"role": 3, "task": "x"}), "").starts_with("[erreur] rôle inconnu : ("));
|
||||
assert!(e(json!({"role": "planner", "task": " \n"}), "").contains("tâche vide"));
|
||||
// Le rôle et la tâche d'abord, la récursion ensuite.
|
||||
assert!(e(json!({"role": "x", "task": "y"}), "explorer").contains("rôle inconnu"));
|
||||
assert!(
|
||||
e(json!({"role": "explorer", "task": "y"}), "explorer")
|
||||
.contains("ne peut pas en appeler un autre")
|
||||
);
|
||||
assert_eq!(
|
||||
precheck(
|
||||
&args(json!({"role": " planner ", "task": " découpe "})),
|
||||
"builder"
|
||||
),
|
||||
Ok(("planner".into(), "découpe".into()))
|
||||
);
|
||||
assert!(precheck(&args(json!({"role": "explorer", "task": "t"})), "").is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rapports() {
|
||||
assert_eq!(report("explorer", " vu \n", None), "[explorer]\nvu");
|
||||
assert_eq!(
|
||||
report("planner", " ", None),
|
||||
"[erreur] le sous-agent planner n'a rien répondu"
|
||||
);
|
||||
assert_eq!(
|
||||
report("explorer", "x", Some("panne")),
|
||||
"[erreur] sous-agent explorer : panne"
|
||||
);
|
||||
let long = "é".repeat(MAX_OUTPUT + 1);
|
||||
let r = report("explorer", &long, None);
|
||||
assert!(r.ends_with("\n…[tronqué]"));
|
||||
let kept = r
|
||||
.trim_start_matches("[explorer]\n")
|
||||
.trim_end_matches("\n…[tronqué]");
|
||||
assert_eq!(kept.chars().count(), MAX_OUTPUT);
|
||||
let pile = "é".repeat(MAX_OUTPUT);
|
||||
assert_eq!(
|
||||
report("explorer", &pile, None),
|
||||
format!("[explorer]\n{pile}")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cadrages() {
|
||||
assert!(framing("planner").contains("criteria tool"));
|
||||
assert!(framing("explorer").contains("file:line"));
|
||||
assert_eq!(framing("code-reviewer"), framing("explorer"));
|
||||
}
|
||||
@@ -58,7 +58,7 @@ pub struct Criterion {
|
||||
|
||||
/// `engineSideJob` : début d'un travail annexe auprès du Go ; `None` s'il ne
|
||||
/// répond pas.
|
||||
async fn side_job_begin(store: &Store) -> Option<i64> {
|
||||
pub(super) async fn side_job_begin(store: &Store) -> Option<i64> {
|
||||
store
|
||||
.internal_post("/internal/v1/engine/sidejob", &json!({}))
|
||||
.await
|
||||
@@ -68,7 +68,7 @@ async fn side_job_begin(store: &Store) -> Option<i64> {
|
||||
|
||||
/// Fin du travail annexe. Les requêtes servies ont déjà été comptées une à
|
||||
/// une (`/internal/v1/oai/served`) : rien à ajouter.
|
||||
async fn side_job_end(store: &Store, id: Option<i64>) {
|
||||
pub(super) async fn side_job_end(store: &Store, id: Option<i64>) {
|
||||
if let Some(id) = id {
|
||||
let _ = store
|
||||
.internal_post(
|
||||
@@ -478,7 +478,7 @@ impl ChatService {
|
||||
queued: Box::new(Vec::new),
|
||||
deltas: {
|
||||
let conv = conv.clone();
|
||||
Box::new(move |d| {
|
||||
Arc::new(move |d| {
|
||||
conv.append_delta(conv.epoch(), d);
|
||||
})
|
||||
},
|
||||
@@ -491,6 +491,15 @@ impl ChatService {
|
||||
side_slot: Default::default(),
|
||||
projsnap: snap_turn,
|
||||
snap_refreshed: Default::default(),
|
||||
// Le builder relancé délègue comme dans son tour ; la
|
||||
// vérification, elle, ne délègue pas (garde-fou du rôle).
|
||||
sub: Some(super::subagent::SubTurn {
|
||||
ep: env.ep.clone(),
|
||||
reasoning: pe.reasoning.clone(),
|
||||
echo: env.echo.clone(),
|
||||
patterns: self.shared.patterns.clone(),
|
||||
cancel: run.cancel.clone(),
|
||||
}),
|
||||
};
|
||||
let mut out = PassOut::default();
|
||||
let mut cb = |ev: StreamEvent| -> bool {
|
||||
|
||||
Reference in new issue
Block a user