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>
204 lines
7.8 KiB
Rust
204 lines
7.8 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,
|
|
},
|
|
}
|
|
|
|
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);
|
|
}
|
|
}
|