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:
2026-06-12 07:33:04 +02:00
parent 5f45c22941
commit eca2ba95c4
61 changed files with 9491 additions and 2512 deletions

203
crates/domain/src/input.rs Normal file
View File

@ -0,0 +1,203 @@
//! Mediated agent input (cadrage « entrée médiée par IdeA », lot C1/C2).
//!
//! Every input to an agent — **human** keystrokes *and* **inter-agent** delegations
//! — converges on **one FIFO per agent**, with `enqueue` (Envoyer) and `preempt`
//! (Interrompre) kept distinct. This module owns the pure value objects
//! ([`InputSource`], [`AgentBusyState`]) and the [`InputMediator`] port.
//!
//! The mediator **absorbs** [`crate::mailbox::AgentMailbox`]: the existing mailbox
//! (FIFO + one-shot reply) is reused as the *correlation engine* inside the infra
//! adapter (`MediatedInbox`), rather than spawning a second concurrent queue. The
//! delegation mailbox is thus just **one input source among two**.
//!
//! Pure (no I/O): the FIFO, locks and busy bookkeeping live in the infra adapter.
use serde::{Deserialize, Serialize};
use crate::ids::AgentId;
use crate::mailbox::{PendingReply, Ticket, TicketId};
use crate::ports::PtyHandle;
/// Where a queued input came from.
///
/// Replaces the free-form `requester: String` as the **source of truth** (the
/// string remains a derived display label). Lets IdeA propagate the requester's
/// identity and feed the wait-for graph (cycle detection).
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "camelCase", tag = "kind")]
pub enum InputSource {
/// The human operator (a `SubmitHumanInput` use case).
Human,
/// Another agent delegating via `idea_ask_agent`.
#[serde(rename_all = "camelCase")]
Agent {
/// The delegating agent.
agent_id: AgentId,
},
}
impl InputSource {
/// Convenience constructor for an agent source.
#[must_use]
pub const fn agent(agent_id: AgentId) -> Self {
Self::Agent { agent_id }
}
/// Returns the [`AgentId`] when this source is an agent.
#[must_use]
pub const fn as_agent(&self) -> Option<AgentId> {
match self {
Self::Agent { agent_id } => Some(*agent_id),
Self::Human => None,
}
}
/// Whether the source is the human operator.
#[must_use]
pub const fn is_human(&self) -> bool {
matches!(self, Self::Human)
}
}
/// Whether an agent is currently processing a task.
///
/// Derived state, published to the front (`AgentBusyChanged`). An agent goes `Busy`
/// on the enqueue that **starts a turn**, and returns `Idle` on prompt-ready OR an
/// explicit signal (cf. cadrage §6). In doubt it stays `Busy`, but the FIFO keeps
/// accepting enqueues (forward, never reject the sender).
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase", tag = "state")]
pub enum AgentBusyState {
/// No turn in flight; the next enqueued ticket can start.
Idle,
/// A turn is in flight.
#[serde(rename_all = "camelCase")]
Busy {
/// The ticket whose turn is running.
ticket: TicketId,
/// When the turn started (epoch millis), for UI age display.
since_ms: u64,
},
}
impl AgentBusyState {
/// Whether a turn is currently in flight.
#[must_use]
pub const fn is_busy(&self) -> bool {
matches!(self, Self::Busy { .. })
}
/// The in-flight ticket, if any.
#[must_use]
pub const fn ticket(&self) -> Option<TicketId> {
match self {
Self::Busy { ticket, .. } => Some(*ticket),
Self::Idle => None,
}
}
}
/// The single convergence point of **all** of an agent's input (one FIFO/agent),
/// with `enqueue` (Envoyer) and `preempt` (Interrompre) kept **distinct**, plus the
/// busy state.
///
/// Object-safe (held as `Arc<dyn InputMediator>`). The infra adapter `MediatedInbox`
/// composes the existing [`crate::mailbox::AgentMailbox`] (correlation by ticket) +
/// turn locks + busy bookkeeping — it does **not** create a second queue.
pub trait InputMediator: Send + Sync {
/// Envoyer = enqueue: appends `ticket` to `agent`'s FIFO and returns the
/// [`PendingReply`] to await (the mailbox reply type, reused).
///
/// **Delivery** (cadrage C3 §5.2): the implementation also **writes** the turn
/// into the agent's input stream — *this* is the single, serialized write path
/// that replaces the orchestrator's former ad-hoc PTY write. The write target is
/// the [`PtyHandle`] previously registered with [`InputMediator::bind_handle`];
/// without one, the enqueue still queues the ticket (forward, never reject) and
/// the orchestrator falls back to its own delivery.
fn enqueue(&self, agent: AgentId, ticket: Ticket) -> PendingReply;
/// Registers (or refreshes) the live input [`PtyHandle`] of `agent` so a later
/// [`InputMediator::enqueue`] delivers the turn through it (cadrage C3 §5.2).
///
/// Keyed by **agent** (one live input stream per agent — «1 agent = 1 employee»);
/// the orchestrator calls this once it has resolved/launched the agent's live
/// session for the target conversation. Default: no-op (a mediator that does not
/// own the delivery write).
fn bind_handle(&self, _agent: AgentId, _handle: PtyHandle) {}
/// Like [`InputMediator::bind_handle`], but also arms **prompt-ready detection**
/// (cadrage §6, lot C5): `prompt_ready_pattern` is the agent profile's optional
/// literal marker (`AgentProfile::prompt_ready_pattern`). When `Some`, the mediator
/// watches the bound handle's output stream and calls [`InputMediator::mark_idle`]
/// the first time the marker appears (one of the two OR signals; the other is an
/// explicit `idea_reply`). When `None`, no pattern detection is armed — the agent
/// only returns `Idle` on the explicit signal or the per-turn timeout (fallback
/// «en cas de doute → reste Busy mais la file accepte»; never a false `Idle`).
///
/// Default: delegates to [`InputMediator::bind_handle`], ignoring the pattern (a
/// mediator that does not observe the output stream). The infra adapter overrides
/// it to arm the watcher.
fn bind_handle_with_prompt(
&self,
agent: AgentId,
handle: PtyHandle,
_prompt_ready_pattern: Option<String>,
) {
self.bind_handle(agent, handle);
}
/// Whether this mediator owns the turn-delivery write for `agent` (i.e. it has a
/// bound handle **and** a PTY port). When `false`, the orchestrator must deliver
/// the turn itself. Default: `false`.
#[must_use]
fn delivers_turn(&self, _agent: AgentId) -> bool {
false
}
/// Interrompre = preempt: signals the running turn to stop (Échap/stop). This
/// is **not** an enqueue and correlates **no** ticket.
fn preempt(&self, agent: AgentId);
/// Marks `agent` free (prompt-ready or explicit signal) so its FIFO advances.
fn mark_idle(&self, agent: AgentId);
/// The current [`AgentBusyState`] of `agent`.
fn busy_state(&self, agent: AgentId) -> AgentBusyState;
}
#[cfg(test)]
mod tests {
use super::*;
fn agent(n: u128) -> AgentId {
AgentId::from_uuid(uuid::Uuid::from_u128(n))
}
fn ticket(n: u128) -> TicketId {
TicketId::from_uuid(uuid::Uuid::from_u128(n))
}
#[test]
fn input_source_human_vs_agent() {
assert!(InputSource::Human.is_human());
assert_eq!(InputSource::Human.as_agent(), None);
let s = InputSource::agent(agent(1));
assert!(!s.is_human());
assert_eq!(s.as_agent(), Some(agent(1)));
}
#[test]
fn busy_state_transitions_and_accessors() {
let idle = AgentBusyState::Idle;
assert!(!idle.is_busy());
assert_eq!(idle.ticket(), None);
let busy = AgentBusyState::Busy {
ticket: ticket(7),
since_ms: 1000,
};
assert!(busy.is_busy());
assert_eq!(busy.ticket(), Some(ticket(7)));
assert_ne!(idle, busy);
}
}