feat(agent): conversation par paire + entrée médiée + pivot terminal/MCP
Coeur inter-agents consolidé et surface front réalignée sur la décision "terminal natif PTY, pas d'UI chat" (Option 1). Domaine - nouveaux modules conversation, mailbox, input, fileguard (ports + types) - orchestrator/profile/events étendus (conversation par paire, FIFO) Application / Infrastructure - orchestrator/service + context_guard : sérialisation FIFO par agent, garde RW mémoire/contexte, dispatch ask/reply - adapters in-memory conversation / mailbox / input / fileguard - registry session + lifecycle agent durcis (1 agent = 1 session vivante) - outils MCP idea_* alignés sur le nouveau dispatch Frontend - MediatedInput + useAgentBusy : entrée utilisateur médiée par IdeA, terminal = vue sortie inchangée - suppression de la vue chat structurée (AgentChatView) — abandonnée - adapter input + ports mis à jour Divers - .ideai/ : mémoire projet + briefs de cadrage versionnés ; requests/ runtime ignoré ; agents projet réels (DevBackend/DevFrontend/QA) Tests : Rust (domain/application/infrastructure/app-tauri) + front (346) verts. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
330
crates/domain/src/mailbox.rs
Normal file
330
crates/domain/src/mailbox.rs
Normal file
@ -0,0 +1,330 @@
|
||||
//! Inter-agent **mailbox** port (Option 1 « Terminal + MCP », lot B-1).
|
||||
//!
|
||||
//! A delegating agent calls `idea_ask_agent(target, task)` and **blocks** until the
|
||||
//! target renders a result via `idea_reply(result)`. Between those two MCP calls,
|
||||
//! IdeA must (1) hand the task to the target's single FIFO input and (2) hold the
|
||||
//! caller's await on a reply slot that the target's later `idea_reply` resolves.
|
||||
//!
|
||||
//! This module owns the **pure** contract of that rendezvous: the [`AgentMailbox`]
|
||||
//! port plus its value objects ([`Ticket`], [`TicketId`], [`MailboxError`]) and the
|
||||
//! [`PendingReply`] handle the caller awaits. It is **I/O-free**: the actual queue,
|
||||
//! its mutex and the one-shot reply channel are an infrastructure concern (the
|
||||
//! `InMemoryMailbox` adapter). Keeping the contract here means the FIFO/resolution
|
||||
//! invariants are expressed against a port the application layer depends on, never
|
||||
//! against a concrete channel type (hexagonal + DIP).
|
||||
//!
|
||||
//! ## Model
|
||||
//!
|
||||
//! - **One queue per target agent** (`AgentId`). `enqueue` appends a [`Ticket`] and
|
||||
//! returns a [`PendingReply`] the caller awaits.
|
||||
//! - **`resolve(agent, result)` corrèle implicitement** the result with the ticket
|
||||
//! at the **head** of that agent's queue — the agent is processing one task at a
|
||||
//! time (1 agent = 1 employee, FIFO input), so its current `idea_reply` answers
|
||||
//! the task it is currently working on. No ticket id is exposed to the model.
|
||||
//! - **Timeout** is the caller's concern: it drops its [`PendingReply`] and calls
|
||||
//! [`AgentMailbox::cancel_head`] to retire the stuck head ticket so the queue
|
||||
//! advances.
|
||||
|
||||
use std::future::Future;
|
||||
use std::pin::Pin;
|
||||
use std::task::{Context, Poll};
|
||||
|
||||
use crate::conversation::ConversationId;
|
||||
use crate::ids::AgentId;
|
||||
use crate::input::InputSource;
|
||||
|
||||
/// Identifies one queued [`Ticket`] within a target agent's mailbox.
|
||||
///
|
||||
/// Newtype around [`uuid::Uuid`]; minted by the adapter on `enqueue`. It is **never
|
||||
/// exposed to the model** (the MCP `idea_reply` schema carries only `result`):
|
||||
/// correlation is positional (head of the FIFO), the id only lets the caller name
|
||||
/// the exact ticket it wants retired on timeout ([`AgentMailbox::cancel_head`]).
|
||||
#[derive(
|
||||
Debug,
|
||||
Clone,
|
||||
Copy,
|
||||
PartialEq,
|
||||
Eq,
|
||||
Hash,
|
||||
PartialOrd,
|
||||
Ord,
|
||||
serde::Serialize,
|
||||
serde::Deserialize,
|
||||
)]
|
||||
#[serde(transparent)]
|
||||
pub struct TicketId(pub uuid::Uuid);
|
||||
|
||||
impl TicketId {
|
||||
/// Wraps an existing [`uuid::Uuid`].
|
||||
#[must_use]
|
||||
pub const fn from_uuid(id: uuid::Uuid) -> Self {
|
||||
Self(id)
|
||||
}
|
||||
|
||||
/// Mints a fresh random ticket id.
|
||||
#[must_use]
|
||||
pub fn new_random() -> Self {
|
||||
Self(uuid::Uuid::new_v4())
|
||||
}
|
||||
|
||||
/// Returns the inner [`uuid::Uuid`].
|
||||
#[must_use]
|
||||
pub const fn as_uuid(&self) -> uuid::Uuid {
|
||||
self.0
|
||||
}
|
||||
}
|
||||
|
||||
impl std::fmt::Display for TicketId {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
write!(f, "{}", self.0)
|
||||
}
|
||||
}
|
||||
|
||||
/// A delegation request queued for a target agent: who asked, and what for.
|
||||
///
|
||||
/// The `id` lets the caller retire **this** ticket on timeout; `requester` and
|
||||
/// `task` are carried so the application layer can prefix the task with the asking
|
||||
/// agent's identity when it writes to the target's input (`[IdeA · tâche de A ·
|
||||
/// ticket …]`). Pure value object (no behaviour, no I/O).
|
||||
///
|
||||
/// **Origin & target** (cadrage C1): a ticket also carries its [`InputSource`]
|
||||
/// (Human or Agent — the *source of truth* for the requester, the `requester`
|
||||
/// string being a derived display label) and the [`ConversationId`] of the thread
|
||||
/// it enters. These are added through **additive** constructors ([`Ticket::from_human`],
|
||||
/// [`Ticket::from_agent`]); [`Ticket::new`] keeps its signature (Open/Closed) and
|
||||
/// defaults to a `Human` source with a nil conversation for back-compat.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct Ticket {
|
||||
/// Stable id of this queued request (minted by the adapter on `enqueue`).
|
||||
pub id: TicketId,
|
||||
/// The origin of this input (Human or a delegating Agent).
|
||||
pub source: InputSource,
|
||||
/// The conversation thread this task enters.
|
||||
pub conversation: ConversationId,
|
||||
/// Display name (or id) of the agent that issued the `idea_ask_agent`.
|
||||
pub requester: String,
|
||||
/// The task/message to deliver to the target agent.
|
||||
pub task: String,
|
||||
}
|
||||
|
||||
impl Ticket {
|
||||
/// Builds a ticket from its parts (back-compat: `Human` source, nil conversation).
|
||||
///
|
||||
/// Preserved for existing call sites. Prefer [`Ticket::from_human`] /
|
||||
/// [`Ticket::from_agent`] when the source and conversation are known.
|
||||
#[must_use]
|
||||
pub fn new(id: TicketId, requester: impl Into<String>, task: impl Into<String>) -> Self {
|
||||
Self {
|
||||
id,
|
||||
source: InputSource::Human,
|
||||
conversation: ConversationId::from_uuid(uuid::Uuid::nil()),
|
||||
requester: requester.into(),
|
||||
task: task.into(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Builds a ticket originating from the **human** operator, for a given thread.
|
||||
#[must_use]
|
||||
pub fn from_human(
|
||||
id: TicketId,
|
||||
conversation: ConversationId,
|
||||
requester: impl Into<String>,
|
||||
task: impl Into<String>,
|
||||
) -> Self {
|
||||
Self {
|
||||
id,
|
||||
source: InputSource::Human,
|
||||
conversation,
|
||||
requester: requester.into(),
|
||||
task: task.into(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Builds a ticket originating from a delegating **agent**, for a given thread.
|
||||
#[must_use]
|
||||
pub fn from_agent(
|
||||
id: TicketId,
|
||||
source: AgentId,
|
||||
conversation: ConversationId,
|
||||
requester: impl Into<String>,
|
||||
task: impl Into<String>,
|
||||
) -> Self {
|
||||
Self {
|
||||
id,
|
||||
source: InputSource::agent(source),
|
||||
conversation,
|
||||
requester: requester.into(),
|
||||
task: task.into(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Errors raised by the [`AgentMailbox`] port.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
|
||||
pub enum MailboxError {
|
||||
/// `resolve` was called for an agent whose queue is **empty** — an
|
||||
/// `idea_reply` with no matching `idea_ask_agent` in flight. Surfaced as a typed
|
||||
/// error (never a panic) so the offending agent can read it and adjust.
|
||||
#[error("no pending request to reply to for agent {0}")]
|
||||
NoPendingRequest(AgentId),
|
||||
/// The awaited reply channel closed before a result arrived — the target's
|
||||
/// session ended (or was killed) without rendering an `idea_reply`. The caller's
|
||||
/// await resolves to this instead of hanging forever.
|
||||
#[error("reply channel closed before a result was rendered")]
|
||||
Cancelled,
|
||||
}
|
||||
|
||||
/// A handle the caller awaits to receive a target agent's reply.
|
||||
///
|
||||
/// Returned by [`AgentMailbox::enqueue`]. Awaiting it yields the `String` result a
|
||||
/// later [`AgentMailbox::resolve`] feeds into this ticket's slot, or
|
||||
/// [`MailboxError::Cancelled`] if the reply channel closes first. The future is
|
||||
/// **opaque** (the adapter builds it from its one-shot receiver): the domain stays
|
||||
/// free of any concrete channel type, and the await point lives in the application
|
||||
/// layer's `ask_agent`.
|
||||
pub struct PendingReply {
|
||||
inner: Pin<Box<dyn Future<Output = Result<String, MailboxError>> + Send>>,
|
||||
}
|
||||
|
||||
impl PendingReply {
|
||||
/// Wraps a reply future built by the adapter (e.g. over a one-shot receiver).
|
||||
#[must_use]
|
||||
pub fn new(
|
||||
inner: Pin<Box<dyn Future<Output = Result<String, MailboxError>> + Send>>,
|
||||
) -> Self {
|
||||
Self { inner }
|
||||
}
|
||||
}
|
||||
|
||||
impl Future for PendingReply {
|
||||
type Output = Result<String, MailboxError>;
|
||||
|
||||
fn poll(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Self::Output> {
|
||||
self.inner.as_mut().poll(cx)
|
||||
}
|
||||
}
|
||||
|
||||
impl std::fmt::Debug for PendingReply {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
f.debug_struct("PendingReply").finish_non_exhaustive()
|
||||
}
|
||||
}
|
||||
|
||||
/// The inter-agent rendezvous port: a per-agent FIFO of delegation tickets, each
|
||||
/// awaiting a one-shot reply (Option 1, lot B-1).
|
||||
///
|
||||
/// **Per-agent FIFO** (1 agent = 1 employee): tickets for the **same** target are
|
||||
/// served head-first; tickets for **different** targets never block one another.
|
||||
/// `enqueue` returns the [`PendingReply`] the caller awaits; the target's later
|
||||
/// `idea_reply` lands in [`AgentMailbox::resolve`], which corrèle it with the head
|
||||
/// of that agent's queue.
|
||||
///
|
||||
/// Kept object-safe (`&self`, owned/`Copy` args) so the application layer holds it
|
||||
/// as `Arc<dyn AgentMailbox>`; the implementation owns all interior mutability.
|
||||
pub trait AgentMailbox: Send + Sync {
|
||||
/// Appends `ticket` to `agent`'s FIFO and returns the handle to await its reply.
|
||||
///
|
||||
/// The returned [`PendingReply`] resolves when a later [`AgentMailbox::resolve`]
|
||||
/// feeds a result to this ticket (once it reaches the head and is answered), or
|
||||
/// to [`MailboxError::Cancelled`] if the reply channel closes first.
|
||||
fn enqueue(&self, agent: AgentId, ticket: Ticket) -> PendingReply;
|
||||
|
||||
/// Resolves the request at the **head** of `agent`'s FIFO with `result`,
|
||||
/// waking its awaiting [`PendingReply`] and removing it from the queue.
|
||||
///
|
||||
/// Positional correlation: the agent processes one task at a time, so its
|
||||
/// current `idea_reply` answers the head ticket.
|
||||
///
|
||||
/// # Errors
|
||||
/// [`MailboxError::NoPendingRequest`] when `agent` has no queued ticket (an
|
||||
/// `idea_reply` with no matching ask in flight).
|
||||
fn resolve(&self, agent: AgentId, result: String) -> Result<(), MailboxError>;
|
||||
|
||||
/// Resolves the request identified by `ticket_id` **anywhere** in `agent`'s FIFO
|
||||
/// with `result`, waking its awaiting [`PendingReply`] and removing it from the
|
||||
/// queue (cadrage C3 §3.3 — deterministic, multi-thread correlation).
|
||||
///
|
||||
/// This is the **by-ticket** counterpart of [`AgentMailbox::resolve`]: when an
|
||||
/// agent answers a delegation it echoes the ticket id from the `[IdeA · … ·
|
||||
/// ticket <id>]` prefix, so IdeA correlates exactly that request even when the
|
||||
/// agent has several threads in flight.
|
||||
///
|
||||
/// The default implementation falls back to [`AgentMailbox::resolve`] (head of
|
||||
/// queue), so a mailbox that does not track ids stays correct for mono-thread
|
||||
/// agents. The in-memory adapter overrides it with true id-keyed resolution.
|
||||
///
|
||||
/// # Errors
|
||||
/// [`MailboxError::NoPendingRequest`] when no queued ticket matches `ticket_id`
|
||||
/// (and, for the default, when `agent`'s queue is empty).
|
||||
fn resolve_ticket(
|
||||
&self,
|
||||
agent: AgentId,
|
||||
_ticket_id: TicketId,
|
||||
result: String,
|
||||
) -> Result<(), MailboxError> {
|
||||
self.resolve(agent, result)
|
||||
}
|
||||
|
||||
/// Retires the ticket `ticket_id` **iff** it is currently at the head of
|
||||
/// `agent`'s FIFO (the caller timed out waiting for it), so the queue advances.
|
||||
///
|
||||
/// A no-op when the head is a different ticket (the timed-out one was already
|
||||
/// resolved, or another caller's ticket is now in front) — idempotent and safe.
|
||||
fn cancel_head(&self, agent: AgentId, ticket_id: TicketId);
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn ticket_carries_its_parts() {
|
||||
let id = TicketId::new_random();
|
||||
let t = Ticket::new(id, "Main", "do X");
|
||||
assert_eq!(t.id, id);
|
||||
assert_eq!(t.requester, "Main");
|
||||
assert_eq!(t.task, "do X");
|
||||
// Back-compat default: human source, nil conversation.
|
||||
assert_eq!(t.source, InputSource::Human);
|
||||
assert_eq!(
|
||||
t.conversation,
|
||||
ConversationId::from_uuid(uuid::Uuid::nil())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn from_human_carries_source_and_conversation() {
|
||||
let conv = ConversationId::from_uuid(uuid::Uuid::from_u128(5));
|
||||
let t = Ticket::from_human(TicketId::new_random(), conv, "User", "do X");
|
||||
assert_eq!(t.source, InputSource::Human);
|
||||
assert_eq!(t.conversation, conv);
|
||||
assert_eq!(t.task, "do X");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn from_agent_carries_agent_source_and_conversation() {
|
||||
let conv = ConversationId::from_uuid(uuid::Uuid::from_u128(6));
|
||||
let from = AgentId::from_uuid(uuid::Uuid::from_u128(2));
|
||||
let t = Ticket::from_agent(TicketId::new_random(), from, conv, "A", "delegate");
|
||||
assert_eq!(t.source, InputSource::agent(from));
|
||||
assert_eq!(t.source.as_agent(), Some(from));
|
||||
assert_eq!(t.conversation, conv);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ticket_id_roundtrips_through_uuid() {
|
||||
let u = uuid::Uuid::from_u128(7);
|
||||
let id = TicketId::from_uuid(u);
|
||||
assert_eq!(id.as_uuid(), u);
|
||||
assert_eq!(id.to_string(), u.to_string());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mailbox_errors_are_distinct_and_typed() {
|
||||
let a = AgentId::from_uuid(uuid::Uuid::from_u128(1));
|
||||
assert_ne!(
|
||||
MailboxError::NoPendingRequest(a),
|
||||
MailboxError::Cancelled
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user