feat(inter-agent): backend des annonces live d'agent (B0-B3)
Redémarre le ticket #4 sur la base develop. Émission d'annonces live d'un agent vers l'UI, distinctes du Final de délégation : - domain: ReplyEvent::Announcement/Final et DomainEvent::AgentAnnouncement (events.rs), port d'émission (ports.rs), gating de readiness (readiness.rs). - application: mapping des événements structurés en annonces (agent/structured.rs, agent/mod.rs, lib.rs) et relais côté orchestrateur (orchestrator/service.rs). - infrastructure/session: parse des annonces + fix du Final pour Claude et Codex, propagé aux adaptateurs et à la conformance (claude.rs, codex.rs, conformance.rs, mod.rs, process.rs, sandbox_e2e.rs). - app-tauri: relais Tauri des annonces vers le front (events.rs, chat.rs). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@ -19,8 +19,9 @@ pub(crate) use lifecycle::ReattachDecision;
|
||||
|
||||
pub use session_limit::{AgentResumer, SessionLimitService, RESUME_PROMPT};
|
||||
pub use structured::{
|
||||
drain_reply_stream_with_readiness, drain_with_readiness, drain_with_readiness_outcome,
|
||||
send_blocking, TurnOutcome,
|
||||
drain_reply_stream_with_readiness, drain_with_readiness,
|
||||
drain_with_readiness_and_announcements, drain_with_readiness_outcome, send_blocking,
|
||||
AnnouncementPublisher, TurnOutcome,
|
||||
};
|
||||
|
||||
pub use catalogue::{
|
||||
|
||||
@ -12,12 +12,17 @@
|
||||
//! 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 std::sync::Arc;
|
||||
use std::time::{Duration, SystemTime, UNIX_EPOCH};
|
||||
|
||||
use domain::conversation::ConversationParty;
|
||||
use domain::events::DomainEvent;
|
||||
use domain::ids::AgentId;
|
||||
use domain::input::InputMediator;
|
||||
use domain::ports::{AgentSession, AgentSessionError, ReplyEvent, ReplyStream};
|
||||
use domain::mailbox::TicketId;
|
||||
use domain::ports::{AgentSession, AgentSessionError, EventBus, ReplyEvent, ReplyStream};
|
||||
use domain::readiness::{ReadinessPolicy, ReadinessSignal};
|
||||
use domain::ProjectId;
|
||||
|
||||
/// Issue d'un tour drainé (ARCHITECTURE §21.2-T4, réconciliation de la limite de
|
||||
/// session avec le contrat « seul `Final` est terminal »).
|
||||
@ -44,6 +49,45 @@ pub enum TurnOutcome {
|
||||
},
|
||||
}
|
||||
|
||||
/// Attribution applicative des annonces live d'un tour inter-agent structuré.
|
||||
#[derive(Clone)]
|
||||
pub struct AnnouncementPublisher {
|
||||
/// Bus d'événements domaine.
|
||||
pub bus: Arc<dyn EventBus>,
|
||||
/// Projet hôte du rendez-vous.
|
||||
pub project_id: ProjectId,
|
||||
/// Partie qui a demandé le tour.
|
||||
pub requester: ConversationParty,
|
||||
/// Agent cible qui produit les annonces.
|
||||
pub target: AgentId,
|
||||
/// Ticket FIFO corrélant le tour.
|
||||
pub ticket: TicketId,
|
||||
}
|
||||
|
||||
impl AnnouncementPublisher {
|
||||
fn publish_event(&self, event: &ReplyEvent) {
|
||||
if let ReplyEvent::Announcement { text } = event {
|
||||
self.bus.publish(DomainEvent::AgentAnnouncement {
|
||||
project_id: self.project_id,
|
||||
requester: self.requester,
|
||||
target: self.target,
|
||||
ticket: self.ticket,
|
||||
text: text.clone(),
|
||||
at_ms: now_epoch_ms(),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn now_epoch_ms() -> u64 {
|
||||
SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.unwrap_or_default()
|
||||
.as_millis()
|
||||
.try_into()
|
||||
.unwrap_or(u64::MAX)
|
||||
}
|
||||
|
||||
/// Envoie `prompt` à la session vivante puis **draine le flux de réponse jusqu'au
|
||||
/// [`ReplyEvent::Final`]**, et retourne son contenu agrégé.
|
||||
///
|
||||
@ -113,6 +157,58 @@ pub async fn drain_with_readiness(
|
||||
}
|
||||
}
|
||||
|
||||
/// Comme [`drain_with_readiness`], mais publie les [`ReplyEvent::Announcement`]
|
||||
/// produits live par la session avec l'attribution applicative complète.
|
||||
pub async fn drain_with_readiness_and_announcements(
|
||||
session: &dyn AgentSession,
|
||||
prompt: &str,
|
||||
timeout: Option<Duration>,
|
||||
mediator: &dyn InputMediator,
|
||||
agent: AgentId,
|
||||
announcements: Option<AnnouncementPublisher>,
|
||||
) -> Result<String, AgentSessionError> {
|
||||
let Some(publisher) = announcements else {
|
||||
return drain_with_readiness(session, prompt, timeout, mediator, agent).await;
|
||||
};
|
||||
|
||||
let (tap_tx, tap_rx) = std::sync::mpsc::channel();
|
||||
let live_publisher = publisher.clone();
|
||||
let live_pump = std::thread::spawn(move || {
|
||||
for event in tap_rx {
|
||||
live_publisher.publish_event(&event);
|
||||
}
|
||||
});
|
||||
|
||||
let stream_result = match timeout {
|
||||
Some(dur) => tokio::time::timeout(dur, session.send_with_tap(prompt, tap_tx))
|
||||
.await
|
||||
.map_err(|_elapsed| AgentSessionError::Timeout)?,
|
||||
None => session.send_with_tap(prompt, tap_tx).await,
|
||||
};
|
||||
let _ = live_pump.join();
|
||||
let stream = stream_result?;
|
||||
|
||||
match drain_stream_to_final(
|
||||
stream,
|
||||
|event| {
|
||||
if !matches!(event, ReplyEvent::Final { .. }) {
|
||||
mediator.mark_alive(agent);
|
||||
}
|
||||
publisher.publish_event(event);
|
||||
},
|
||||
|signal| {
|
||||
if signal == ReadinessSignal::TurnEnded {
|
||||
mediator.mark_idle(agent);
|
||||
}
|
||||
},
|
||||
)? {
|
||||
TurnOutcome::Completed(content) => Ok(content),
|
||||
TurnOutcome::RateLimited { .. } => Err(AgentSessionError::Io(
|
||||
"le tour s'est clos en limite de session, sans contenu Final".to_string(),
|
||||
)),
|
||||
}
|
||||
}
|
||||
|
||||
/// Draine un flux de réponse déjà ouvert, avec la même politique de readiness que
|
||||
/// [`drain_with_readiness`].
|
||||
///
|
||||
@ -265,8 +361,9 @@ fn drain_stream_to_final(
|
||||
match event {
|
||||
ReplyEvent::Final { content } => return Ok(TurnOutcome::Completed(content)),
|
||||
ReplyEvent::RateLimited { resets_at_ms } => last_rate_limit = Some(resets_at_ms),
|
||||
// TextDelta / ToolActivity / Heartbeat : non terminaux, ignorés ici.
|
||||
// TextDelta / Announcement / ToolActivity / Heartbeat : non terminaux.
|
||||
ReplyEvent::TextDelta { .. }
|
||||
| ReplyEvent::Announcement { .. }
|
||||
| ReplyEvent::ToolActivity { .. }
|
||||
| ReplyEvent::Heartbeat => {}
|
||||
}
|
||||
@ -348,6 +445,20 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
struct RecordingBus {
|
||||
events: Mutex<Vec<DomainEvent>>,
|
||||
}
|
||||
impl EventBus for RecordingBus {
|
||||
fn publish(&self, event: DomainEvent) {
|
||||
self.events.lock().unwrap().push(event);
|
||||
}
|
||||
|
||||
fn subscribe(&self) -> domain::ports::EventStream {
|
||||
Box::new(std::iter::empty())
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn drain_marks_alive_on_each_non_terminal_then_idle_on_final() {
|
||||
let session = FakeSession {
|
||||
@ -374,4 +485,68 @@ mod tests {
|
||||
"un battement par événement non terminal, idle au Final (pas de battement sur le Final)"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn drain_publishes_announcement_but_only_final_marks_idle_and_resolves() {
|
||||
let session = FakeSession {
|
||||
events: vec![
|
||||
ReplyEvent::Announcement {
|
||||
text: "je travaille".into(),
|
||||
},
|
||||
ReplyEvent::Final {
|
||||
content: "fini".into(),
|
||||
},
|
||||
],
|
||||
};
|
||||
let mediator = RecordingMediator::default();
|
||||
let bus = Arc::new(RecordingBus::default());
|
||||
let project_id = ProjectId::from_uuid(uuid::Uuid::from_u128(10));
|
||||
let requester = ConversationParty::User;
|
||||
let target = agent(2);
|
||||
let ticket = TicketId::from_uuid(uuid::Uuid::from_u128(11));
|
||||
|
||||
let out = drain_with_readiness_and_announcements(
|
||||
&session,
|
||||
"go",
|
||||
None,
|
||||
&mediator,
|
||||
target,
|
||||
Some(AnnouncementPublisher {
|
||||
bus: bus.clone(),
|
||||
project_id,
|
||||
requester,
|
||||
target,
|
||||
ticket,
|
||||
}),
|
||||
)
|
||||
.await
|
||||
.expect("drain ok");
|
||||
|
||||
assert_eq!(out, "fini");
|
||||
assert_eq!(
|
||||
*mediator.calls.lock().unwrap(),
|
||||
vec!["alive", "idle"],
|
||||
"Announcement est non terminale ; seul Final marque Idle"
|
||||
);
|
||||
let events = bus.events.lock().unwrap();
|
||||
assert_eq!(events.len(), 1);
|
||||
match &events[0] {
|
||||
DomainEvent::AgentAnnouncement {
|
||||
project_id: p,
|
||||
requester: r,
|
||||
target: t,
|
||||
ticket: tk,
|
||||
text,
|
||||
at_ms,
|
||||
} => {
|
||||
assert_eq!(*p, project_id);
|
||||
assert_eq!(*r, requester);
|
||||
assert_eq!(*t, target);
|
||||
assert_eq!(*tk, ticket);
|
||||
assert_eq!(text, "je travaille");
|
||||
assert!(*at_ms > 0);
|
||||
}
|
||||
other => panic!("événement inattendu: {other:?}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user