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:
@ -18,7 +18,7 @@
|
||||
//! making the reference profiles addressable across runs without a registry.
|
||||
|
||||
use domain::ids::ProfileId;
|
||||
use domain::profile::{AgentProfile, ContextInjection};
|
||||
use domain::profile::{AgentProfile, ContextInjection, StructuredAdapter};
|
||||
|
||||
/// A fixed UUID namespace used to derive stable ids for reference profiles.
|
||||
/// (Random-looking but constant; only its stability matters.)
|
||||
@ -56,7 +56,8 @@ pub fn reference_profiles() -> Vec<AgentProfile> {
|
||||
"{agentRunDir}",
|
||||
None,
|
||||
)
|
||||
.expect("claude reference profile is valid"),
|
||||
.expect("claude reference profile is valid")
|
||||
.with_structured_adapter(StructuredAdapter::Claude),
|
||||
AgentProfile::new(
|
||||
reference_id("codex"),
|
||||
"OpenAI Codex CLI",
|
||||
@ -68,7 +69,8 @@ pub fn reference_profiles() -> Vec<AgentProfile> {
|
||||
"{agentRunDir}",
|
||||
None,
|
||||
)
|
||||
.expect("codex reference profile is valid"),
|
||||
.expect("codex reference profile is valid")
|
||||
.with_structured_adapter(StructuredAdapter::Codex),
|
||||
AgentProfile::new(
|
||||
reference_id("gemini"),
|
||||
"Gemini CLI",
|
||||
|
||||
@ -10,10 +10,13 @@ mod catalogue;
|
||||
mod inspect;
|
||||
mod lifecycle;
|
||||
mod resume;
|
||||
mod structured;
|
||||
mod usecases;
|
||||
|
||||
pub(crate) use lifecycle::unique_md_path;
|
||||
|
||||
pub use structured::send_blocking;
|
||||
|
||||
pub use catalogue::{reference_profile_id, reference_profiles};
|
||||
pub use inspect::{InspectConversation, InspectConversationInput, InspectConversationOutput};
|
||||
pub use resume::{
|
||||
|
||||
73
crates/application/src/agent/structured.rs
Normal file
73
crates/application/src/agent/structured.rs
Normal file
@ -0,0 +1,73 @@
|
||||
//! Helper applicatif `send_blocking` — le rendez-vous synchrone inter-agents
|
||||
//! au-dessus du port [`AgentSession`] (ARCHITECTURE §17.1 / §17.4).
|
||||
//!
|
||||
//! `AgentSession::send` retourne un **flux** d'événements (`ReplyStream`), à la
|
||||
//! manière de `PtyPort::subscribe_output`, mais **typé** : deltas de texte →
|
||||
//! activités d'outil → **un** événement terminal déterministe
|
||||
//! [`ReplyEvent::Final`]. Le rendez-vous synchrone dont l'orchestrateur a besoin
|
||||
//! (§17.4) s'obtient en **drainant ce flux jusqu'au `Final`** : c'est *la* primitive
|
||||
//! de la messagerie inter-agents, **sans outbox, sans corrélation fichier** (le
|
||||
//! `Final` *est* la fin de tour).
|
||||
//!
|
||||
//! DRY : un seul chemin de lecture (le flux). `send_blocking` n'est qu'un *consom-
|
||||
//! mateur* du même flux que la cellule chat utilise pour le rendu incrémental.
|
||||
|
||||
use std::time::Duration;
|
||||
|
||||
use domain::ports::{AgentSession, AgentSessionError, ReplyEvent};
|
||||
|
||||
/// Envoie `prompt` à la session vivante puis **draine le flux de réponse jusqu'au
|
||||
/// [`ReplyEvent::Final`]**, et retourne son contenu agrégé.
|
||||
///
|
||||
/// C'est le rendez-vous synchrone (§17.4) : on attend que le tour soit
|
||||
/// déterministiquement terminé (`Final`) avant de rendre la main. Les deltas de
|
||||
/// texte et les activités d'outil traversés en chemin sont **ignorés** ici (ils
|
||||
/// servent le rendu incrémental côté UI, pas l'appelant synchrone).
|
||||
///
|
||||
/// `timeout`, lorsqu'il est fourni, borne l'attente : si aucun `Final` n'est observé
|
||||
/// dans le délai, on retourne [`AgentSessionError::Timeout`] **sans tuer la
|
||||
/// session** (elle reste vivante dans le registre ; l'appelant décide de la suite).
|
||||
/// `None` ⇒ pas de borne temporelle (on attend la fin du tour).
|
||||
///
|
||||
/// # Errors
|
||||
/// - [`AgentSessionError::Io`]/[`AgentSessionError::Decode`] remontées par `send`
|
||||
/// (échec de communication / décodage de la sortie structurée) ;
|
||||
/// - [`AgentSessionError::Io`] si le flux se termine **sans** `Final` (tour
|
||||
/// interrompu) ;
|
||||
/// - [`AgentSessionError::Timeout`] si `timeout` expire avant le `Final`.
|
||||
pub async fn send_blocking(
|
||||
session: &dyn AgentSession,
|
||||
prompt: &str,
|
||||
timeout: Option<Duration>,
|
||||
) -> Result<String, AgentSessionError> {
|
||||
match timeout {
|
||||
Some(dur) => match tokio::time::timeout(dur, drain_to_final(session, prompt)).await {
|
||||
Ok(result) => result,
|
||||
// La session **reste vivante** : on ne `shutdown` rien ici (§17.1).
|
||||
Err(_elapsed) => Err(AgentSessionError::Timeout),
|
||||
},
|
||||
None => drain_to_final(session, prompt).await,
|
||||
}
|
||||
}
|
||||
|
||||
/// Ouvre le flux du tour (`send`) et le **draine jusqu'au `Final`**.
|
||||
///
|
||||
/// Le flux ([`domain::ports::ReplyStream`]) est un itérateur synchrone et borné :
|
||||
/// après le `Final` il ne produit plus rien. On le parcourt donc simplement
|
||||
/// jusqu'à rencontrer le `Final` (et on retourne son contenu) ; si le flux
|
||||
/// s'épuise avant, c'est un tour interrompu → erreur [`AgentSessionError::Io`].
|
||||
async fn drain_to_final(
|
||||
session: &dyn AgentSession,
|
||||
prompt: &str,
|
||||
) -> Result<String, AgentSessionError> {
|
||||
let stream = session.send(prompt).await?;
|
||||
for event in stream {
|
||||
if let ReplyEvent::Final { content } = event {
|
||||
return Ok(content);
|
||||
}
|
||||
// TextDelta / ToolActivity : ignorés par le rendez-vous synchrone.
|
||||
}
|
||||
Err(AgentSessionError::Io(
|
||||
"le flux de réponse s'est terminé sans événement Final".to_string(),
|
||||
))
|
||||
}
|
||||
Reference in New Issue
Block a user