//! 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, }, } /// Whether an agent currently shows **signs of life** during a turn (lot 2, /// chantier readiness/heartbeat — détection « Stalled »). /// /// Orthogonal to [`AgentBusyState`]: an agent can be `Busy` **and** `Alive` (it is /// working, producing deltas/heartbeats) or `Busy` **and** `Stalled` (no proof of /// liveness for longer than its profile's `stall_after_ms`). Only meaningful while /// `Busy`; an `Idle` agent is considered `Alive` (its turn ended cleanly). /// /// Published to the front (`AgentLivenessChanged`) **once per transition** so the UI /// can badge a frozen agent without event spam. Derived from the per-agent /// `last_seen_ms` heartbeat, never from parsing the model output. #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "camelCase", tag = "liveness")] pub enum AgentLiveness { /// The agent is producing proof of liveness (deltas / tool activity / /// heartbeats) within its `stall_after_ms` window — or is idle. Alive, /// No proof of liveness for longer than the profile's `stall_after_ms`: the /// agent is presumed frozen. Advisory only — the FIFO and the turn keep running /// (a late heartbeat flips it back to [`Self::Alive`]). Stalled, } 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, } } } /// Per-agent submit configuration (ARCHITECTURE §20.3), resolved from the target /// agent's profile and carried to the [`InputMediator`] at bind time so it can be /// echoed on a [`crate::events::DomainEvent::DelegationReady`]. /// /// Both fields stay `Option`: the **default** (`"\r"`, ~60 ms) is applied at the /// point of use (the frontend write-portal), never hard-coded in the domain — the /// domain only transports the declared values. #[derive(Debug, Clone, Default, PartialEq, Eq)] pub struct SubmitConfig { /// The profile's `submit_sequence` (e.g. `"\r"`). `None` ⇒ front default. pub sequence: Option, /// The profile's `submit_delay_ms`. `None` ⇒ front default (~60 ms). pub delay_ms: Option, } impl SubmitConfig { /// Builds a submit config from the two optional profile fields. #[must_use] pub const fn new(sequence: Option, delay_ms: Option) -> Self { Self { sequence, delay_ms } } } /// 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** (ARCHITECTURE §20.2/§20.3): the mediator **no longer writes the /// turn into the PTY** (the `\n` band-aid is gone — it never submitted in raw mode /// and produced a "double chat"). Instead, on the enqueue that **starts a turn** /// (Idle→Busy), the adapter publishes a [`crate::events::DomainEvent::DelegationReady`] /// carrying the task text + ticket + the target's [`SubmitConfig`]; the frontend /// cell (sole owner of the terminal) runs the write-portal handshake and writes the /// text + submit sequence through the single PTY writer. The mediator stays the /// authority of the FIFO/busy state and correlation only. fn enqueue(&self, agent: AgentId, ticket: Ticket) -> PendingReply; /// Headless/system enqueue: appends `ticket` to the same FIFO and marks the agent /// busy, but does **not** deliver any text to the human terminal surface. /// /// This is for inter-agent structured/headless turns where the real transport is /// [`crate::ports::AgentSession::send`] and the answer is its final response. The /// mailbox ticket remains useful for queue/workstate accounting and cancellation, /// but publishing [`crate::events::DomainEvent::DelegationReady`] here would leak /// the same task into the user's CLI cell. /// /// Default keeps compatibility for simple mediators; production overrides it to /// suppress delivery. fn enqueue_silent(&self, agent: AgentId, ticket: Ticket) -> PendingReply { self.enqueue(agent, ticket) } /// 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 records the target's /// [`SubmitConfig`] (ARCHITECTURE §20.3) so the adapter can echo /// `submit_sequence`/`submit_delay_ms` on the /// [`crate::events::DomainEvent::DelegationReady`] published when a turn starts. /// The bind is the natural carrier: the submit config is per-agent profile data the /// orchestrator resolves at bind time, so it travels with the handle (no extra /// ticket field, no second resolve). /// /// Turn-end detection is **no longer** armed here: the dead PTY prompt-ready sniff /// was replaced by the transcript [`crate::ports::TurnWatcher`] (armed at the /// composition root, once per live session) which calls /// [`InputMediator::turn_ended`]. /// /// Default: delegates to [`InputMediator::bind_handle`], ignoring the submit config. /// The infra adapter overrides it to stash the submit config. fn bind_handle_with_submit(&self, agent: AgentId, handle: PtyHandle, _submit: SubmitConfig) { self.bind_handle(agent, handle); } /// **Deprecated since ARCHITECTURE §20**: the mediator never writes the turn into /// the PTY anymore — the **frontend** always delivers (via the write-portal). Kept /// for source compatibility; it now always returns `false`. Callers should stop /// branching on it (the orchestrator no longer falls back to its own PTY write). #[must_use] fn delivers_turn(&self, _agent: AgentId) -> bool { false } /// Déclare qu'`agent` vient d'être **lancé à froid** : la livraison de son tout /// premier tour doit être *gatée* sur la readiness MCP (la `DelegationReady` est /// différée jusqu'à la connexion du pont MCP du CLI), pour éviter d'écrire la tâche /// avant que le CLI ait fini de booter (premier tour perdu sinon). /// /// À n'appeler **que** lorsqu'un signal de readiness le libérera (le profil porte un /// pont MCP ⇒ [`InputMediator::release_cold_start`]). Sans signal pour le libérer, /// gater le premier tour le bloquerait indéfiniment ; dans ce cas l'orchestrateur ne /// doit **pas** appeler `mark_starting` et l'`enqueue` livre la tâche immédiatement /// (chemin chaud, fallback sûr, zéro régression). /// /// Default: no-op (a mediator that does not gate cold starts). fn mark_starting(&self, _agent: AgentId) {} /// Signal de **readiness de démarrage** : le pont MCP de `agent` vient de se /// connecter (son CLI est up et a chargé les outils `idea_*`). Si un premier tour /// a été différé par [`InputMediator::mark_starting`] (démarrage à froid), c'est le /// moment de le livrer ⇒ draine le `DelegationReady` retenu. **Contrairement à /// `turn_ended`, aucun repli `mark_idle`** : c'est un signal de DÉMARRAGE, pas de /// fin de tour. Idempotent : un second appel ne trouve rien et est un no-op. C'est /// désormais le **seul** drain du tour différé (le signal MCP-initialize), le /// watcher prompt-ready PTY ayant été supprimé. /// /// Default: no-op (médiateur qui ne gate pas les démarrages à froid). fn release_cold_start(&self, _agent: AgentId) {} /// Déclare si une **cellule terminal du frontend** est montée pour `agent` /// (`true` au montage du write-portal, `false` au démontage). C'est le frontend /// qui possède l'écriture physique du PTY *quand une cellule existe* (cas d'un /// agent épinglé dans le layout) ; un agent **délégué en arrière-plan n'a aucune /// cellule**, donc personne ne consomme `DelegationReady` côté UI. Le médiateur /// utilise cet état pour décider, à la livraison d'un tour, entre **publier /// l'événement** (cellule présente ⇒ le front écrit) et **écrire lui-même** la /// tâche dans le PTY (agent headless ⇒ sinon le tour est perdu). /// /// Default: no-op (médiateur sans notion de cellule front — tout passe par /// l'événement, comportement historique). fn set_front_attached(&self, _agent: AgentId, _attached: bool) {} /// 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 (explicit signal) so its FIFO advances. fn mark_idle(&self, agent: AgentId); /// **End-of-turn** signal: the agent's transcript just recorded a completed turn /// (the [`crate::ports::TurnWatcher`] fired), and **no** `idea_reply` carried a /// result. This is the no-reply backstop trigger: the adapter captures the active /// ticket, marks the agent `Idle` (advancing the FIFO), then arms a short **grace** /// window — if no `idea_reply` correlates the ticket by then, it completes the turn /// "without reply" (waking a parked caller with a typed error instead of leaving it /// blocked until the long timeout). A late `idea_reply` within the grace wins. /// /// Replaces the former prompt-ready watcher branch verbatim; only the **trigger** /// changed (transcript `turn_duration` instead of a PTY prompt sigil). Default: /// [`InputMediator::mark_idle`] (a mediator with no mailbox/grace just advances). fn turn_ended(&self, agent: AgentId) { self.mark_idle(agent); } /// Records a **proof of liveness** (« battement ») for `agent` — called on every /// non-terminal turn event (text delta, tool activity, [`crate::ports::ReplyEvent::Heartbeat`]) /// by the drain loop. Refreshes the per-agent `last_seen` timestamp so the stall /// detector ([`crate::events::DomainEvent::AgentLivenessChanged`], lot 2) knows the /// agent is still working; a battement arriving after a `Stalled` transition flips it /// back to [`AgentLiveness::Alive`]. /// /// Default: no-op (a mediator that does not track liveness). The infra adapter /// `MediatedInbox` overrides it to refresh `last_seen` and publish an /// `AgentLivenessChanged{Stalled→Alive}` recovery on the first late battement. fn mark_alive(&self, _agent: AgentId) {} /// Declares the agent's **stall threshold** (its profile's /// [`crate::profile::LivenessStrategy::stall_after_ms`]) so the stall detector knows /// how long without a battement means "frozen" (lot 2). The orchestrator resolves it /// from the target's profile and calls this **before** the enqueue that starts a /// turn; the adapter stashes it and arms a fresh liveness window when the turn /// begins. `None` ⇒ no stall detection for this agent (legacy / no `liveness` block — /// zero regression). /// /// Default: no-op (a mediator that does not track liveness). fn set_stall_threshold(&self, _agent: AgentId, _stall_after_ms: Option) {} /// 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); } }