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:
203
crates/domain/src/input.rs
Normal file
203
crates/domain/src/input.rs
Normal 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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user