//! 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 { 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 { 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`). 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, ) { 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); } }