feat(agent): fondation exécution structurée des agents IA (D0+D1) — §17

Pivot orchestration : agents IA pilotés via leur mode programmatique/JSON
(capture déterministe), au lieu du TUI brut + self-report. §16 (idea/MCP)
marquée remplacée comme voie principale.

- D0 (domaine) : port AgentSession + AgentSessionFactory, types ReplyEvent
  /ReplyStream/AgentSessionError, champ AgentProfile.structured_adapter
  (Option<StructuredAdapter{Claude,Codex}>, skip si None ⇒ zéro régression),
  catalogue Claude/Codex annotés.
- D1 (application) : registre StructuredSessions (jumeau de TerminalSessions),
  agrégateur LiveSessions{pty,structured} derrière LiveAgentRegistry (vivant si
  PTY OU structuré, surface du trait inchangée), helper send_blocking (draine le
  ReplyStream jusqu'au Final, Timeout sans tuer la session).

Tests : domaine 16+2 ; application registre 11 + send_blocking 9 ; workspace 0 échec.
A/B intacts. Aucun adapter concret (D2), pas de Tauri/front.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-09 17:54:48 +02:00
parent 7375f706da
commit 5e10b5eb42
17 changed files with 2084 additions and 11 deletions

View File

@ -14,3 +14,4 @@ async-trait = { workspace = true }
[dev-dependencies]
serde_json = { workspace = true }
tokio = { workspace = true }

View File

@ -189,6 +189,41 @@ pub type OutputStream = Box<dyn Iterator<Item = Vec<u8>> + Send>;
/// A boxed stream of domain events, returned by [`EventBus::subscribe`].
pub type EventStream = Box<dyn Iterator<Item = DomainEvent> + Send>;
/// Un événement incrémental d'un tour de réponse d'un agent IA (ARCHITECTURE §17.1).
///
/// Universel : l'adapter (Claude/Codex) traduit SON format structuré documenté
/// vers ces variantes ; **aucun** détail propre à une CLI (pas de `stream-json`,
/// pas de `--output-format`, pas de chemin de transcript) ne franchit cette
/// frontière domaine.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum ReplyEvent {
/// Un fragment de texte assistant (rendu incrémental côté UI chat).
TextDelta {
/// Le fragment de texte.
text: String,
},
/// Une activité d'outil de l'agent (best-effort, pour l'observabilité chat :
/// « lit un fichier », « lance une commande »). Le `label` est déjà
/// humain-lisible ; le détail brut reste dans l'adapter.
ToolActivity {
/// Libellé humain-lisible de l'activité.
label: String,
},
/// **Événement terminal déterministe** d'un tour : l'adapter l'émet quand il a
/// lu le message `result` documenté de la CLI. Porte le contenu final agrégé.
/// Après `Final`, le flux se termine (plus aucun événement).
Final {
/// Le contenu final agrégé du tour.
content: String,
},
}
/// Flux borné d'événements de réponse d'UN tour (ARCHITECTURE §17.1). Se termine
/// après le [`ReplyEvent::Final`] (ou sur erreur). Calqué sur [`OutputStream`],
/// mais **typé** : deltas de texte → activités d'outil → un `Final` déterministe,
/// plutôt que des octets bruts.
pub type ReplyStream = Box<dyn Iterator<Item = ReplyEvent> + Send>;
// ---------------------------------------------------------------------------
// Per-port error types
// ---------------------------------------------------------------------------
@ -218,6 +253,29 @@ pub enum PtyError {
NotFound,
}
/// Errors from an [`AgentSession`] / [`AgentSessionFactory`] (ARCHITECTURE §17.1).
///
/// Frontière nette : on ne propage **jamais** le JSON brut d'une CLI à travers
/// ces erreurs (cf. [`AgentSessionError::Decode`]).
#[derive(Debug, Clone, PartialEq, Eq, Error)]
pub enum AgentSessionError {
/// La session programmatique n'a pas pu démarrer (CLI introuvable, mode
/// structuré indisponible, handshake invalide).
#[error("agent session start failed: {0}")]
Start(String),
/// Échec d'envoi/de communication avec la session vivante.
#[error("agent session io failed: {0}")]
Io(String),
/// La sortie structurée de la CLI n'a pas pu être décodée (JSON cassé, schéma
/// inattendu). On ne propage jamais le JSON brut.
#[error("agent session decode failed: {0}")]
Decode(String),
/// `send_blocking` n'a pas observé de [`ReplyEvent::Final`] dans le temps
/// imparti. La session **reste vivante** (on ne tue rien) ; l'appelant décide.
#[error("agent session reply timed out")]
Timeout,
}
/// Errors from [`ProcessSpawner`].
#[derive(Debug, Clone, PartialEq, Eq, Error)]
pub enum ProcessError {
@ -402,6 +460,71 @@ pub trait AgentRuntime: Send + Sync {
) -> Result<SpawnSpec, RuntimeError>;
}
/// Une **session programmatique persistante** avec un agent IA (ARCHITECTURE §17.1) :
/// une conversation vivante que l'on pilote en mode structuré et dont on lit la
/// réponse de façon déterministe. Une instance ⇔ un agent IA (invariant « 1 session
/// vivante/agent », porté au niveau *type*).
///
/// Hexagonal : ce trait est **domaine** ; les adapters Claude/Codex (infra) ne
/// fuient aucun détail de CLI à travers lui. Substituable (Liskov) : Claude et
/// Codex offrent les mêmes garanties (flux d'événements → [`ReplyEvent::Final`]
/// déterministe), seul le moteur diffère.
///
/// Calqué sur [`PtyPort`] (consommé comme trait-objet `Arc<dyn AgentSession>`,
/// d'où `#[async_trait]` pour rester object-safe — cf. note d'en-tête du module).
#[async_trait]
pub trait AgentSession: Send + Sync {
/// L'id de session IdeA (mappe la cellule/agent, comme un [`PtyHandle::session_id`]).
fn id(&self) -> SessionId;
/// L'id de conversation **du moteur** (opaque), persisté sur la cellule pour la
/// reprise (§15.2). `None` tant que le moteur n'en a pas attribué. Permet à
/// `LeafCell.conversation_id` de rester le pivot de reprise, model-agnostic.
fn conversation_id(&self) -> Option<String>;
/// Transmet `prompt` à la session vivante et retourne le **flux** d'événements
/// du tour (deltas → [`ReplyEvent::Final`]). Rendu incrémental (UI chat) ET
/// base du rendez-vous synchrone (helper applicatif `send_blocking`).
///
/// # Errors
/// [`AgentSessionError::Io`]/[`AgentSessionError::Decode`] sur échec de
/// communication/décodage.
async fn send(&self, prompt: &str) -> Result<ReplyStream, AgentSessionError>;
/// Termine proprement la session (tue le process/SDK sous-jacent). Idempotent.
///
/// # Errors
/// [`AgentSessionError::Io`] si l'arrêt échoue.
async fn shutdown(&self) -> Result<(), AgentSessionError>;
}
/// **Factory** sélectionnée par le profil (ARCHITECTURE §17.1) : crée/reprend une
/// [`AgentSession`] pour un agent IA. C'est elle qui sait *quel adapter* instancier
/// (Claude/Codex) selon `profile.structured_adapter` (§17.3). Open/Closed : ajouter
/// un moteur structuré = ajouter un adapter + une variante de registre, sans
/// toucher au cœur.
#[async_trait]
pub trait AgentSessionFactory: Send + Sync {
/// Vrai si cette factory sait piloter `profile` en mode structuré (sert au menu
/// de sélection §17.6 : ne proposer que les profils supportés).
fn supports(&self, profile: &AgentProfile) -> bool;
/// Démarre une session structurée pour `profile` dans `cwd` (run dir isolé
/// §14.1), avec le contexte déjà préparé ([`PreparedContext`]) et l'intention de
/// session ([`SessionPlan`] : neuf / assign / resume — réutilise §15).
///
/// # Errors
/// [`AgentSessionError::Start`] si la CLI/SDK est indisponible ou le mode
/// structuré ne peut s'initialiser.
async fn start(
&self,
profile: &AgentProfile,
ctx: &PreparedContext,
cwd: &ProjectPath,
session: &SessionPlan,
) -> Result<Arc<dyn AgentSession>, AgentSessionError>;
}
/// Open and drive pseudo-terminals.
#[async_trait]
pub trait PtyPort: Send + Sync {

View File

@ -118,6 +118,22 @@ impl SessionStrategy {
}
}
/// Adapter d'**exécution structurée** qui pilote un profil IA (ARCHITECTURE §17).
///
/// Déclaratif, Open/Closed (comme [`EmbedderStrategy`]) : un profil déclare quel
/// adapter le pilote en mode programmatique. Ajouter un moteur structuré = une
/// variante ici + un adapter infra (`infrastructure/src/session/`), sans toucher
/// au cœur. Un profil **sans** `structured_adapter` reste un profil **TUI/PTY**
/// (terminal brut, comportement historique).
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub enum StructuredAdapter {
/// Piloté par `ClaudeSdkSession` (mode `-p --output-format stream-json` / SDK).
Claude,
/// Piloté par `CodexExecSession` (`codex exec` structuré).
Codex,
}
/// Declarative runtime configuration for one AI CLI.
///
/// Invariants:
@ -148,6 +164,15 @@ pub struct AgentProfile {
/// keeps today's behaviour.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub session: Option<SessionStrategy>,
/// Adapter d'exécution **structurée** (ARCHITECTURE §17). `None` ⇒ agent
/// **TUI/PTY** (cellule terminal brut, comportement historique). `Some(_)` ⇒
/// agent **IA structuré** (cellule chat + port [`crate::ports::AgentSession`]).
/// Open/Closed : ajouter un moteur = une variante + un adapter.
///
/// `skip_serializing_if = Option::is_none` ⇒ **zéro régression** de
/// sérialisation : un profil sans adapter sérialise exactement comme avant.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub structured_adapter: Option<StructuredAdapter>,
}
/// Embedding strategy of an [`EmbedderProfile`] (LOT C, étage 2 vectoriel).
@ -281,6 +306,16 @@ impl AgentProfile {
detect,
cwd_template,
session,
structured_adapter: None,
})
}
/// Builder : fixe l'[`StructuredAdapter`] d'exécution structurée (§17) et
/// renvoie le profil. Laisse [`AgentProfile::new`] stable (zéro régression
/// d'appel) : les profils TUI/PTY ne l'appellent simplement pas.
#[must_use]
pub fn with_structured_adapter(mut self, adapter: StructuredAdapter) -> Self {
self.structured_adapter = Some(adapter);
self
}
}

View File

@ -0,0 +1,396 @@
//! LOT D0 (fondation §17.1) — tests purs du domaine :
//! - `StructuredAdapter` : round-trip serde camelCase (`"claude"` / `"codex"`) ;
//! - `AgentProfile.structured_adapter` : zéro régression serde (omission via
//! `skip_serializing_if`, défaut `None` à la désérialisation), round-trip avec
//! adapter, et builder `with_structured_adapter` qui ne touche rien d'autre ;
//! - types du port `AgentSession` : `ReplyEvent` (3 variantes + égalité),
//! `AgentSessionError` (variantes + `Display`/`Error`), et un **fake in-module**
//! prouvant que `AgentSession`/`AgentSessionFactory` sont implémentables (D0 = pas
//! d'impl réelle, seulement la conformité de signature).
//!
//! Style calqué sur `serde_roundtrip.rs` / `agent_profile_a0.rs` (réutilise les
//! constructeurs validants du domaine, pas de littéraux fragiles).
use std::sync::Arc;
use async_trait::async_trait;
use domain::ids::{ProfileId, SessionId};
use domain::ports::{
AgentSession, AgentSessionError, AgentSessionFactory, PreparedContext, ReplyEvent, ReplyStream,
SessionPlan,
};
use domain::profile::{AgentProfile, ContextInjection, StructuredAdapter};
use domain::project::ProjectPath;
use domain::MarkdownDoc;
use uuid::Uuid;
fn profid(n: u128) -> ProfileId {
ProfileId::from_uuid(Uuid::from_u128(n))
}
fn roundtrip<T>(value: &T) -> T
where
T: serde::Serialize + serde::de::DeserializeOwned + PartialEq + std::fmt::Debug,
{
let json = serde_json::to_string(value).expect("serialize");
serde_json::from_str(&json).expect("deserialize")
}
// ---------------------------------------------------------------------------
// StructuredAdapter — round-trip serde camelCase ("claude" / "codex")
// ---------------------------------------------------------------------------
#[test]
fn structured_adapter_serialises_camel_case() {
assert_eq!(
serde_json::to_string(&StructuredAdapter::Claude).unwrap(),
"\"claude\""
);
assert_eq!(
serde_json::to_string(&StructuredAdapter::Codex).unwrap(),
"\"codex\""
);
}
#[test]
fn structured_adapter_deserialises_from_camel_case() {
let claude: StructuredAdapter = serde_json::from_str("\"claude\"").unwrap();
let codex: StructuredAdapter = serde_json::from_str("\"codex\"").unwrap();
assert_eq!(claude, StructuredAdapter::Claude);
assert_eq!(codex, StructuredAdapter::Codex);
}
#[test]
fn structured_adapter_roundtrips_both_variants() {
for adapter in [StructuredAdapter::Claude, StructuredAdapter::Codex] {
assert_eq!(roundtrip(&adapter), adapter);
}
}
#[test]
fn structured_adapter_rejects_unknown_variant() {
// Un JSON hors vocabulaire ne doit pas se faufiler (pas de défaut silencieux).
let parsed: Result<StructuredAdapter, _> = serde_json::from_str("\"gemini\"");
assert!(parsed.is_err(), "unknown adapter must fail to deserialise");
}
// ---------------------------------------------------------------------------
// AgentProfile.structured_adapter — zéro régression serde
// ---------------------------------------------------------------------------
/// Profil minimal TUI/PTY (sans adapter), construit via le constructeur validant.
fn pty_profile() -> AgentProfile {
AgentProfile::new(
profid(1),
"Gemini CLI",
"gemini",
vec![],
ContextInjection::convention_file("GEMINI.md").unwrap(),
Some("gemini --version".to_owned()),
"{agentRunDir}",
None,
)
.unwrap()
}
#[test]
fn profile_without_adapter_omits_the_field() {
// skip_serializing_if = Option::is_none ⇒ la clé `structuredAdapter` est ABSENTE.
let p = pty_profile();
assert_eq!(p.structured_adapter, None);
let json = serde_json::to_string(&p).unwrap();
assert!(
!json.contains("structuredAdapter"),
"field must be omitted when None; json was {json}"
);
// Et le round-trip reste fidèle.
assert_eq!(roundtrip(&p), p);
}
#[test]
fn legacy_profile_without_adapter_deserialises_to_none() {
// Un `profiles.json` produit AVANT que le champ existe : pas de clé
// `structuredAdapter` ⇒ défaut `None` (zéro régression de désérialisation).
let json = r#"{
"id": "00000000-0000-0000-0000-000000000001",
"name": "Gemini CLI",
"command": "gemini",
"args": [],
"contextInjection": { "strategy": "conventionFile", "target": "GEMINI.md" },
"detect": null,
"cwdTemplate": "{agentRunDir}"
}"#;
let p: AgentProfile = serde_json::from_str(json).expect("legacy profile must deserialise");
assert_eq!(p.structured_adapter, None);
}
#[test]
fn profile_with_adapter_roundtrips_and_uses_camel_case() {
let p = pty_profile().with_structured_adapter(StructuredAdapter::Claude);
let json = serde_json::to_string(&p).unwrap();
// Clé camelCase + valeur camelCase de la variante.
assert!(
json.contains("\"structuredAdapter\":\"claude\""),
"json was {json}"
);
assert!(!json.contains("structured_adapter"), "json was {json}");
assert_eq!(roundtrip(&p), p);
// Codex aussi.
let c = pty_profile().with_structured_adapter(StructuredAdapter::Codex);
let json = serde_json::to_string(&c).unwrap();
assert!(
json.contains("\"structuredAdapter\":\"codex\""),
"json was {json}"
);
assert_eq!(roundtrip(&c), c);
}
#[test]
fn with_structured_adapter_sets_only_that_field() {
let before = pty_profile();
let after = before.clone().with_structured_adapter(StructuredAdapter::Codex);
// Le seul champ muté :
assert_eq!(after.structured_adapter, Some(StructuredAdapter::Codex));
assert_ne!(after.structured_adapter, before.structured_adapter);
// Tous les autres champs strictement inchangés :
assert_eq!(after.id, before.id);
assert_eq!(after.name, before.name);
assert_eq!(after.command, before.command);
assert_eq!(after.args, before.args);
assert_eq!(after.context_injection, before.context_injection);
assert_eq!(after.detect, before.detect);
assert_eq!(after.cwd_template, before.cwd_template);
assert_eq!(after.session, before.session);
// Preuve d'équivalence : ne diffère du `before` que par l'adapter posé.
let mut patched = before;
patched.structured_adapter = Some(StructuredAdapter::Codex);
assert_eq!(after, patched);
}
#[test]
fn with_structured_adapter_is_last_write_wins() {
// Re-poser un adapter écrase le précédent (idempotent par valeur).
let p = pty_profile()
.with_structured_adapter(StructuredAdapter::Claude)
.with_structured_adapter(StructuredAdapter::Codex);
assert_eq!(p.structured_adapter, Some(StructuredAdapter::Codex));
}
#[test]
fn new_defaults_structured_adapter_to_none() {
// Le constructeur `new` reste stable : il ne pose jamais d'adapter.
assert_eq!(pty_profile().structured_adapter, None);
}
// ---------------------------------------------------------------------------
// ReplyEvent — 3 variantes construites, égalité, clone
// ---------------------------------------------------------------------------
#[test]
fn reply_event_three_variants_construct_and_carry_payload() {
let delta = ReplyEvent::TextDelta {
text: "hel".to_owned(),
};
let tool = ReplyEvent::ToolActivity {
label: "reads a file".to_owned(),
};
let final_ = ReplyEvent::Final {
content: "hello world".to_owned(),
};
match &delta {
ReplyEvent::TextDelta { text } => assert_eq!(text, "hel"),
other => panic!("expected TextDelta, got {other:?}"),
}
match &tool {
ReplyEvent::ToolActivity { label } => assert_eq!(label, "reads a file"),
other => panic!("expected ToolActivity, got {other:?}"),
}
match &final_ {
ReplyEvent::Final { content } => assert_eq!(content, "hello world"),
other => panic!("expected Final, got {other:?}"),
}
}
#[test]
fn reply_event_equality_and_clone() {
let e = ReplyEvent::Final {
content: "done".to_owned(),
};
assert_eq!(e.clone(), e);
// Même variante, payload différent ⇒ inégal.
assert_ne!(
e,
ReplyEvent::Final {
content: "other".to_owned()
}
);
// Variantes différentes ⇒ inégal.
assert_ne!(
ReplyEvent::TextDelta { text: "x".into() },
ReplyEvent::ToolActivity { label: "x".into() }
);
}
// ---------------------------------------------------------------------------
// AgentSessionError — variantes + Display / std::error::Error
// ---------------------------------------------------------------------------
#[test]
fn agent_session_error_display_messages() {
assert_eq!(
AgentSessionError::Start("cli missing".to_owned()).to_string(),
"agent session start failed: cli missing"
);
assert_eq!(
AgentSessionError::Io("broken pipe".to_owned()).to_string(),
"agent session io failed: broken pipe"
);
assert_eq!(
AgentSessionError::Decode("bad json".to_owned()).to_string(),
"agent session decode failed: bad json"
);
assert_eq!(
AgentSessionError::Timeout.to_string(),
"agent session reply timed out"
);
}
#[test]
fn agent_session_error_is_std_error_and_equates() {
// Conformité au trait std::error::Error (thiserror).
fn assert_error<E: std::error::Error>(_e: &E) {}
let e = AgentSessionError::Decode("x".to_owned());
assert_error(&e);
// Clone + égalité par variante/payload.
assert_eq!(e.clone(), e);
assert_ne!(
AgentSessionError::Start("a".into()),
AgentSessionError::Start("b".into())
);
assert_ne!(AgentSessionError::Timeout, AgentSessionError::Io("t".into()));
}
// ---------------------------------------------------------------------------
// AgentSession / AgentSessionFactory — fake in-module (conformité de signature)
//
// D0 ne fournit PAS d'impl réelle : ce fake prouve seulement que les traits sont
// implémentables (object-safe, signatures cohérentes). Aucune logique réelle.
// ---------------------------------------------------------------------------
struct FakeSession {
id: SessionId,
conversation_id: Option<String>,
}
#[async_trait]
impl AgentSession for FakeSession {
fn id(&self) -> SessionId {
self.id
}
fn conversation_id(&self) -> Option<String> {
self.conversation_id.clone()
}
async fn send(&self, prompt: &str) -> Result<ReplyStream, AgentSessionError> {
// Flux borné minimal : un delta puis le Final déterministe.
let stream: ReplyStream = Box::new(
vec![
ReplyEvent::TextDelta {
text: prompt.to_owned(),
},
ReplyEvent::Final {
content: prompt.to_owned(),
},
]
.into_iter(),
);
Ok(stream)
}
async fn shutdown(&self) -> Result<(), AgentSessionError> {
Ok(())
}
}
struct FakeFactory;
#[async_trait]
impl AgentSessionFactory for FakeFactory {
fn supports(&self, profile: &AgentProfile) -> bool {
profile.structured_adapter.is_some()
}
async fn start(
&self,
_profile: &AgentProfile,
_ctx: &PreparedContext,
_cwd: &ProjectPath,
_session: &SessionPlan,
) -> Result<Arc<dyn AgentSession>, AgentSessionError> {
Ok(Arc::new(FakeSession {
id: SessionId::from_uuid(Uuid::from_u128(7)),
conversation_id: None,
}))
}
}
#[tokio::test]
async fn fake_session_proves_trait_is_implementable() {
let sid = SessionId::from_uuid(Uuid::from_u128(42));
let session = FakeSession {
id: sid,
conversation_id: Some("conv-1".to_owned()),
};
// Consommé comme trait-objet (object-safety du port via #[async_trait]).
let dyn_session: Arc<dyn AgentSession> = Arc::new(session);
assert_eq!(dyn_session.id(), sid);
assert_eq!(dyn_session.conversation_id(), Some("conv-1".to_owned()));
// send -> flux d'événements borné se terminant par Final (contrat §17.1).
let events: Vec<ReplyEvent> = dyn_session.send("ping").await.unwrap().collect();
assert_eq!(
events,
vec![
ReplyEvent::TextDelta {
text: "ping".to_owned()
},
ReplyEvent::Final {
content: "ping".to_owned()
},
]
);
assert!(matches!(events.last(), Some(ReplyEvent::Final { .. })));
dyn_session.shutdown().await.expect("shutdown ok");
}
#[tokio::test]
async fn fake_factory_supports_only_structured_profiles_and_starts() {
let factory: Arc<dyn AgentSessionFactory> = Arc::new(FakeFactory);
let structured = pty_profile().with_structured_adapter(StructuredAdapter::Claude);
let pty = pty_profile();
assert!(factory.supports(&structured));
assert!(!factory.supports(&pty));
let ctx = PreparedContext {
content: MarkdownDoc::new("# ctx"),
relative_path: "CLAUDE.md".to_owned(),
};
let cwd = ProjectPath::new("/srv/run").unwrap();
let session = factory
.start(&structured, &ctx, &cwd, &SessionPlan::None)
.await
.expect("factory starts a session");
assert_eq!(session.id(), SessionId::from_uuid(Uuid::from_u128(7)));
}