343 lines
16 KiB
Rust
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);
|
|
}
|
|
}
|