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:
Claude committed 2026-10-16 07:12:29 +00:00
1 parent 92e9adee7d
commit ded1c97c17
16 files changed
+1846 -73

No files matched your search

+8 -1
View File
@@ -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.
+83 -57
View File
@@ -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);
+1
View File
@@ -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;
+2
View File
@@ -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";
+309
View File
@@ -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;
+65
View File
@@ -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"));
}
+12 -3
View File
@@ -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 {