Files
IdeA/crates/domain/src/input.rs
2026-06-27 12:42:37 +02:00

343 lines
16 KiB
Rust

//! 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,
},
}
/// 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<TicketId> {
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<String>,
/// The profile's `submit_delay_ms`. `None` ⇒ front default (~60 ms).
pub delay_ms: Option<u32>,
}
impl SubmitConfig {
/// Builds a submit config from the two optional profile fields.
#[must_use]
pub const fn new(sequence: Option<String>, delay_ms: Option<u32>) -> 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<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** (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<u32>) {}
/// 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);
}
}