feat(agent): conversation par paire + entrée médiée + pivot terminal/MCP

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>
This commit is contained in:
2026-06-12 07:33:04 +02:00
parent 5f45c22941
commit eca2ba95c4
61 changed files with 9491 additions and 2512 deletions

View File

@ -0,0 +1,431 @@
//! Conversation-by-pair domain model (cadrage « conversation par paire », lot C1/C2).
//!
//! A [`Conversation`] is a **thread between two distinct parties** with its own
//! I/O session. Its identity is the **unordered pair** `{left, right}`: the same
//! two parties always denote the same conversation (this is the key to the
//! *lazy materialisation* the [`ConversationRegistry`] performs — `resolve(a, b)`
//! and `resolve(b, a)` yield the same [`ConversationId`]).
//!
//! This module is **pure** (ARCHITECTURE dependency rule): no `tokio`, no
//! `std::fs`, no `std::process`. It owns the value objects and entities plus the
//! [`ConversationRegistry`] port (a trait the application depends on; the infra
//! provides `InMemoryConversationRegistry`). The session handle is held only as a
//! [`SessionRef`] (a reference to a [`crate::terminal::TerminalSession`]); the
//! domain never holds the PTY itself.
//!
//! ## Why this exists
//!
//! The previous coupling «1 live session / agent» was ambiguous once an agent can
//! take part in several threads (`session-registry-agent-ambiguity`). Here the live
//! session is keyed **by conversation**, so a delegation `A↔B` never borrows the
//! `User↔B` thread — the context leak is closed by construction.
use serde::{Deserialize, Serialize};
use crate::ids::{AgentId, SessionId};
/// Identifies one [`Conversation`].
///
/// Newtype around [`uuid::Uuid`], calqué sur [`crate::ids::AgentId`] /
/// [`crate::mailbox::TicketId`]. Minted by the [`ConversationRegistry`] when a pair
/// is first resolved; stable for the lifetime of that pair's thread.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize)]
#[serde(transparent)]
pub struct ConversationId(pub uuid::Uuid);
impl ConversationId {
/// Wraps an existing [`uuid::Uuid`].
#[must_use]
pub const fn from_uuid(id: uuid::Uuid) -> Self {
Self(id)
}
/// Mints a fresh random conversation id.
#[must_use]
pub fn new_random() -> Self {
Self(uuid::Uuid::new_v4())
}
/// Returns the inner [`uuid::Uuid`].
#[must_use]
pub const fn as_uuid(&self) -> uuid::Uuid {
self.0
}
}
impl std::fmt::Display for ConversationId {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "{}", self.0)
}
}
/// One end of a [`Conversation`].
///
/// Invariant (enforced by [`Conversation::try_new`]): a conversation relates **two
/// distinct** parties and carries **at most one** [`ConversationParty::User`]
/// (never `User↔User`, never `Agent(x)↔Agent(x)`).
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "camelCase", tag = "kind")]
pub enum ConversationParty {
/// The human operator — a single logical instance on IdeA's side.
User,
/// A project agent.
#[serde(rename_all = "camelCase")]
Agent {
/// The agent on this end.
agent_id: AgentId,
},
}
impl ConversationParty {
/// Convenience constructor for an agent party.
#[must_use]
pub const fn agent(agent_id: AgentId) -> Self {
Self::Agent { agent_id }
}
/// Returns the [`AgentId`] when this party is an agent.
#[must_use]
pub const fn as_agent(&self) -> Option<AgentId> {
match self {
Self::Agent { agent_id } => Some(*agent_id),
Self::User => None,
}
}
/// Whether this party is the human user.
#[must_use]
pub const fn is_user(&self) -> bool {
matches!(self, Self::User)
}
}
/// Errors raised when constructing a [`Conversation`].
#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
pub enum ConversationError {
/// The two ends are the **same** party (`left == right`).
#[error("a conversation must relate two distinct parties")]
SameParty,
/// **Both** ends are [`ConversationParty::User`] — at most one is allowed.
#[error("a conversation may carry at most one User party")]
TwoUsers,
}
/// The I/O state of a conversation thread.
///
/// The domain holds only a [`SessionRef`]; the live PTY/structured stream is an
/// infrastructure concern (`session-registry`).
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase", tag = "state")]
pub enum ConversationSession {
/// Never launched, or suspended (only `resumable_id` is retained on the
/// [`Conversation`]).
Dormant,
/// A live I/O stream backs this thread.
#[serde(rename_all = "camelCase")]
Live {
/// Reference to the backing [`crate::terminal::TerminalSession`].
handle_ref: SessionRef,
},
}
impl ConversationSession {
/// Whether the thread currently has a live session.
#[must_use]
pub const fn is_live(&self) -> bool {
matches!(self, Self::Live { .. })
}
}
/// An opaque reference to a live session handle (a [`crate::terminal::TerminalSession`]).
///
/// The domain never owns the PTY; it only names the session by its [`SessionId`].
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(transparent)]
pub struct SessionRef(pub SessionId);
impl SessionRef {
/// Wraps a [`SessionId`].
#[must_use]
pub const fn new(id: SessionId) -> Self {
Self(id)
}
/// Returns the referenced [`SessionId`].
#[must_use]
pub const fn session_id(&self) -> SessionId {
self.0
}
}
/// A thread between two distinct parties, with its own session and resumable id.
///
/// Identity is the **unordered pair** `{left, right}`: see [`Conversation::same_pair`].
/// Pure value/entity — no I/O.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct Conversation {
/// Stable identifier (minted by the registry on first resolve).
pub id: ConversationId,
/// One end of the thread.
pub left: ConversationParty,
/// The other end of the thread.
pub right: ConversationParty,
/// Current I/O state.
pub session: ConversationSession,
/// CLI session-id to resume on restart (`suspend` stores it; `Dormant` keeps it).
pub resumable_id: Option<String>,
}
impl Conversation {
/// Builds a `Dormant` conversation for the pair `{left, right}`.
///
/// # Errors
/// - [`ConversationError::SameParty`] if `left == right`.
/// - [`ConversationError::TwoUsers`] if both ends are [`ConversationParty::User`].
pub fn try_new(
id: ConversationId,
left: ConversationParty,
right: ConversationParty,
) -> Result<Self, ConversationError> {
if left.is_user() && right.is_user() {
// More specific than SameParty: "at most one User" invariant.
return Err(ConversationError::TwoUsers);
}
if left == right {
return Err(ConversationError::SameParty);
}
Ok(Self {
id,
left,
right,
session: ConversationSession::Dormant,
resumable_id: None,
})
}
/// Whether this conversation relates the **same unordered pair** as `{a, b}`.
///
/// `{left, right}` is order-insensitive: `same_pair(a, b) == same_pair(b, a)`.
#[must_use]
pub fn same_pair(&self, a: ConversationParty, b: ConversationParty) -> bool {
(self.left == a && self.right == b) || (self.left == b && self.right == a)
}
}
/// A pure wait-for graph for deadlock (cycle) detection on inter-agent delegation.
///
/// Each edge `(A, B)` reads «A is waiting for B». A delegation `from → to` would
/// deadlock iff `to` already (transitively) waits on `from`; [`WaitForGraph::would_cycle`]
/// answers that **without** mutating the graph, so the caller can refuse the ask
/// **before** posting the edge. 100 % pure and testable.
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct WaitForGraph {
edges: Vec<(AgentId, AgentId)>,
}
impl WaitForGraph {
/// An empty graph.
#[must_use]
pub fn new() -> Self {
Self { edges: Vec::new() }
}
/// Records that `from` waits for `to` (no-op if the edge already exists).
pub fn add_edge(&mut self, from: AgentId, to: AgentId) {
if !self.edges.contains(&(from, to)) {
self.edges.push((from, to));
}
}
/// Removes the edge `from → to` (the wait resolved or timed out).
pub fn remove_edge(&mut self, from: AgentId, to: AgentId) {
self.edges.retain(|e| *e != (from, to));
}
/// Number of recorded edges (test/inspection helper).
#[must_use]
pub fn len(&self) -> usize {
self.edges.len()
}
/// Whether the graph holds no edges.
#[must_use]
pub fn is_empty(&self) -> bool {
self.edges.is_empty()
}
/// Whether adding `from → to` would close a cycle in the wait-for graph.
///
/// `true` when `to` already reaches `from` through existing edges (so `from`
/// waiting on `to` would deadlock), or when `from == to` (self-wait). The graph
/// is **not** modified.
#[must_use]
pub fn would_cycle(&self, from: AgentId, to: AgentId) -> bool {
if from == to {
return true;
}
// Does `to` already reach `from`? DFS over existing edges from `to`.
let mut stack = vec![to];
let mut seen = vec![to];
while let Some(node) = stack.pop() {
if node == from {
return true;
}
for (a, b) in &self.edges {
if *a == node && !seen.contains(b) {
seen.push(*b);
stack.push(*b);
}
}
}
false
}
}
/// Lazy, get-or-create registry of conversations keyed by **unordered pair**.
///
/// Pure port (no I/O); the infra adapter `InMemoryConversationRegistry` owns the
/// interior mutability (`HashMap` + sync mutex, never held across an `.await`).
/// Kept object-safe so the application holds it as `Arc<dyn ConversationRegistry>`.
pub trait ConversationRegistry: Send + Sync {
/// Get-or-create: returns the thread for the pair `{a, b}`, opening it
/// (`Dormant`) if it did not exist. Pure registry — opens **no** session.
/// The same unordered pair always yields the same [`ConversationId`].
fn resolve(&self, a: ConversationParty, b: ConversationParty) -> Conversation;
/// Marks the conversation `id` `Live` with the given session reference.
fn bind_session(&self, id: ConversationId, session: SessionRef);
/// Suspends `id` (back to `Dormant`), retaining `resumable_id` for restart.
fn suspend(&self, id: ConversationId, resumable_id: Option<String>);
/// Returns the current snapshot of `id`, if it exists.
fn get(&self, id: ConversationId) -> Option<Conversation>;
}
#[cfg(test)]
mod tests {
use super::*;
fn agent(n: u128) -> AgentId {
AgentId::from_uuid(uuid::Uuid::from_u128(n))
}
fn conv_id(n: u128) -> ConversationId {
ConversationId::from_uuid(uuid::Uuid::from_u128(n))
}
#[test]
fn conversation_id_roundtrips_through_uuid() {
let u = uuid::Uuid::from_u128(42);
let id = ConversationId::from_uuid(u);
assert_eq!(id.as_uuid(), u);
assert_eq!(id.to_string(), u.to_string());
}
#[test]
fn rejects_same_party_pair() {
let a = ConversationParty::agent(agent(1));
assert_eq!(
Conversation::try_new(conv_id(1), a, a),
Err(ConversationError::SameParty)
);
}
#[test]
fn rejects_two_users() {
assert_eq!(
Conversation::try_new(
conv_id(1),
ConversationParty::User,
ConversationParty::User
),
Err(ConversationError::TwoUsers)
);
}
#[test]
fn accepts_user_agent_and_agent_agent() {
assert!(Conversation::try_new(
conv_id(1),
ConversationParty::User,
ConversationParty::agent(agent(1))
)
.is_ok());
assert!(Conversation::try_new(
conv_id(2),
ConversationParty::agent(agent(1)),
ConversationParty::agent(agent(2))
)
.is_ok());
}
#[test]
fn fresh_conversation_is_dormant_without_resumable() {
let c = Conversation::try_new(
conv_id(1),
ConversationParty::User,
ConversationParty::agent(agent(1)),
)
.unwrap();
assert_eq!(c.session, ConversationSession::Dormant);
assert!(!c.session.is_live());
assert_eq!(c.resumable_id, None);
}
#[test]
fn identity_is_the_unordered_pair() {
let a = ConversationParty::agent(agent(1));
let b = ConversationParty::agent(agent(2));
let c = Conversation::try_new(conv_id(1), a, b).unwrap();
assert!(c.same_pair(a, b));
assert!(c.same_pair(b, a), "pair identity is order-insensitive");
let other = ConversationParty::agent(agent(3));
assert!(!c.same_pair(a, other));
}
#[test]
fn would_cycle_refuses_direct_back_edge() {
// A→B exists; B→A would close the cycle.
let mut g = WaitForGraph::new();
g.add_edge(agent(1), agent(2)); // A waits B
assert!(g.would_cycle(agent(2), agent(1)), "B→A closes A→B→A");
}
#[test]
fn would_cycle_allows_linear_chain() {
// A→B exists; B→C is fine (no path C→…→B).
let mut g = WaitForGraph::new();
g.add_edge(agent(1), agent(2)); // A waits B
assert!(
!g.would_cycle(agent(2), agent(3)),
"A→B→C is acyclic"
);
}
#[test]
fn would_cycle_detects_transitive_cycle() {
// A→B, B→C; C→A would close A→B→C→A.
let mut g = WaitForGraph::new();
g.add_edge(agent(1), agent(2));
g.add_edge(agent(2), agent(3));
assert!(g.would_cycle(agent(3), agent(1)), "transitive cycle");
}
#[test]
fn would_cycle_refuses_self_wait() {
let g = WaitForGraph::new();
assert!(g.would_cycle(agent(1), agent(1)));
}
#[test]
fn add_edge_is_idempotent_and_removable() {
let mut g = WaitForGraph::new();
g.add_edge(agent(1), agent(2));
g.add_edge(agent(1), agent(2));
assert_eq!(g.len(), 1);
g.remove_edge(agent(1), agent(2));
assert!(g.is_empty());
}
}

View File

@ -59,6 +59,17 @@ pub enum DomainEvent {
/// Exit code.
code: i32,
},
/// An agent's **busy/idle** state changed (cadrage C4 §4.2): it went `Busy` on
/// the enqueue that started a turn, or `Idle` on `mark_idle` (prompt-ready or an
/// explicit signal). Discrete, low-frequency beacon relayed to the front so the
/// mediated-input view can dim "Envoyer" while a turn is in flight (it never
/// blocks the enqueue — the FIFO keeps accepting; the flag is purely advisory).
AgentBusyChanged {
/// The agent whose state changed.
agent_id: AgentId,
/// `true` when a turn is in flight, `false` when the agent is idle.
busy: bool,
},
/// An agent's runtime profile was changed (hot-swap of the AI engine).
AgentProfileChanged {
/// The agent.

View File

@ -0,0 +1,236 @@
//! File-access guard (cadrage « FileGuard », lot C6).
//!
//! A **reader/writer lock per guarded resource**, bounded to the files IdeA owns:
//! an agent's `.md` context, the project's global context, and a memory note.
//! N concurrent readers **or** a single exclusive writer per resource; the global
//! [`GuardedResource::ProjectContext`] is additionally **single-writer**: only the
//! orchestrator may `acquire_write` it — any other party must *propose* instead and
//! receives [`GuardError::Forbidden`].
//!
//! ## Cooperative scope (cadrage §9.5)
//!
//! This guard corrects collisions **inside the IdeA path** (access through the MCP
//! tools and the UI). It is **cooperative**: an agent that keeps a raw shell can
//! still write these `.md`/memory files directly through the filesystem, bypassing
//! the lock. Real airtightness (revoking raw fs access to these paths) is an **OS
//! sandbox** concern (Landlock — `agent-permissions-architecture`) and is **out of
//! scope** here. The FileGuard fixes IdeA↔IdeA races; it does not sandbox an agent.
//!
//! This module is **pure** (no `tokio`, no `std::fs`): it owns the value objects
//! ([`GuardedResource`], [`GuardError`]), the RAII leases ([`ReadLease`],
//! [`WriteLease`]) and the [`FileGuard`] port. The infra adapter (`RwFileGuard`)
//! provides the actual reader/writer locks.
use async_trait::async_trait;
use crate::conversation::ConversationParty;
use crate::ids::AgentId;
use crate::memory::MemorySlug;
/// The **bounded, closed** set of resources the [`FileGuard`] arbitrates.
///
/// Closed by construction: only an agent's context, the global project context, and
/// a memory note are guardable. Anything outside this enum is **not** guarded (and
/// must not be routed through the guard).
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
pub enum GuardedResource {
/// One agent's `.md` context file.
AgentContext(AgentId),
/// The project's global context (`CLAUDE.md`/root context).
///
/// **Single-writer**: only the orchestrator may write it; every other party is
/// limited to *proposing* a change (cf. [`GuardError::Forbidden`]).
ProjectContext,
/// A single memory note, keyed by its slug.
Memory(MemorySlug),
}
impl GuardedResource {
/// Whether this resource is the single-writer global project context.
#[must_use]
pub const fn is_project_context(&self) -> bool {
matches!(self, Self::ProjectContext)
}
}
/// Errors raised when acquiring a guard lease.
#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
pub enum GuardError {
/// The lease could not be taken because the resource is held incompatibly
/// (a writer wanted while readers/a writer hold it, or vice-versa) and the
/// caller asked for a **non-blocking** acquire. Blocking acquires serialize
/// instead of returning this.
#[error("guarded resource is busy")]
Busy,
/// A party that is **not** the orchestrator attempted to `acquire_write` the
/// single-writer [`GuardedResource::ProjectContext`]. It must *propose* the
/// change for validation rather than writing directly.
#[error("writing the global project context is reserved to the orchestrator; propose instead")]
Forbidden,
}
/// A RAII **read** lease: holding it guarantees shared read access to the resource;
/// dropping it releases the reader slot. N read leases may coexist on one resource.
///
/// The lease owns an opaque release guard (a boxed handle the adapter fills with its
/// lock guard). The domain only knows the lease releases on drop.
#[must_use = "a read lease releases the guard as soon as it is dropped"]
pub struct ReadLease {
_guard: Box<dyn Send + Sync>,
}
impl ReadLease {
/// Wraps an adapter-provided release guard into a domain [`ReadLease`].
///
/// The infra adapter passes its own lock guard (e.g. a tokio `OwnedRwLockReadGuard`);
/// the domain treats it opaquely and only relies on its `Drop` to release.
#[must_use]
pub fn new(guard: Box<dyn Send + Sync>) -> Self {
Self { _guard: guard }
}
}
impl std::fmt::Debug for ReadLease {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str("ReadLease")
}
}
/// A RAII **write** lease: holding it guarantees exclusive write access to the
/// resource; dropping it releases the writer slot. At most one write lease exists
/// for a resource at a time, and it excludes all readers.
#[must_use = "a write lease releases the guard as soon as it is dropped"]
pub struct WriteLease {
_guard: Box<dyn Send + Sync>,
}
impl WriteLease {
/// Wraps an adapter-provided release guard into a domain [`WriteLease`].
#[must_use]
pub fn new(guard: Box<dyn Send + Sync>) -> Self {
Self { _guard: guard }
}
}
impl std::fmt::Debug for WriteLease {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str("WriteLease")
}
}
/// A reader/writer guard over the bounded set of IdeA-owned files
/// ([`GuardedResource`]).
///
/// Contract:
/// - `acquire_read` grants a shared [`ReadLease`]; **N readers** may hold one
/// resource concurrently.
/// - `acquire_write` grants an exclusive [`WriteLease`]; it excludes all other
/// readers and writers of the **same** resource (different resources are
/// independent).
/// - [`GuardedResource::ProjectContext`] is **single-writer**: `acquire_write` by a
/// `who` that is not the orchestrator returns [`GuardError::Forbidden`].
///
/// Object-safe (held as `Arc<dyn FileGuard>`); blocking acquires serialize, so the
/// only error is `Forbidden` (the cooperative path never spuriously fails a wait).
#[async_trait]
pub trait FileGuard: Send + Sync {
/// Acquires a shared **read** lease on `res` for `who`. Serializes behind any
/// in-flight writer of the same resource, then returns a [`ReadLease`].
///
/// # Errors
/// [`GuardError`] — reading is never `Forbidden`, so a blocking adapter returns
/// `Ok` once the writer (if any) releases.
async fn acquire_read(
&self,
who: ConversationParty,
res: GuardedResource,
) -> Result<ReadLease, GuardError>;
/// Acquires an exclusive **write** lease on `res` for `who`. Serializes behind
/// any in-flight readers/writer of the same resource.
///
/// # Errors
/// [`GuardError::Forbidden`] when `who` is not the orchestrator and `res` is the
/// single-writer [`GuardedResource::ProjectContext`].
async fn acquire_write(
&self,
who: ConversationParty,
res: GuardedResource,
) -> Result<WriteLease, GuardError>;
}
/// Whether `who` is allowed to **write** `res` directly (vs. having to propose).
///
/// Pure policy, shared by the port's documented contract and the adapter: only the
/// orchestrator may write the global [`GuardedResource::ProjectContext`]; everyone
/// may write the per-agent context and memory. The orchestrator is modelled as
/// [`ConversationParty::User`] — IdeA's single logical operator identity (the
/// human-driven orchestrator), never a project [`AgentId`].
#[must_use]
pub fn may_write_directly(who: ConversationParty, res: &GuardedResource) -> bool {
if res.is_project_context() {
is_orchestrator(who)
} else {
true
}
}
/// Whether `who` is the orchestrator identity for the single-writer rule.
///
/// The orchestrator is the human-driven operator ([`ConversationParty::User`]); a
/// project agent ([`ConversationParty::Agent`]) is never the orchestrator and must
/// propose changes to the global context.
#[must_use]
pub const fn is_orchestrator(who: ConversationParty) -> bool {
matches!(who, ConversationParty::User)
}
#[cfg(test)]
mod tests {
use super::*;
fn agent_party(n: u128) -> ConversationParty {
ConversationParty::agent(AgentId::from_uuid(uuid::Uuid::from_u128(n)))
}
#[test]
fn project_context_is_single_writer_to_orchestrator_only() {
assert!(may_write_directly(
ConversationParty::User,
&GuardedResource::ProjectContext
));
assert!(!may_write_directly(
agent_party(1),
&GuardedResource::ProjectContext
));
}
#[test]
fn agent_context_and_memory_are_writable_by_anyone() {
let mem = GuardedResource::Memory(MemorySlug::new("note").unwrap());
let ctx = GuardedResource::AgentContext(AgentId::from_uuid(uuid::Uuid::from_u128(9)));
for who in [ConversationParty::User, agent_party(1)] {
assert!(may_write_directly(who, &mem));
assert!(may_write_directly(who, &ctx));
}
}
#[test]
fn is_orchestrator_only_for_user() {
assert!(is_orchestrator(ConversationParty::User));
assert!(!is_orchestrator(agent_party(1)));
}
#[test]
fn guarded_resource_equality_keys_on_identity() {
let a = GuardedResource::AgentContext(AgentId::from_uuid(uuid::Uuid::from_u128(1)));
let b = GuardedResource::AgentContext(AgentId::from_uuid(uuid::Uuid::from_u128(1)));
let c = GuardedResource::AgentContext(AgentId::from_uuid(uuid::Uuid::from_u128(2)));
assert_eq!(a, b);
assert_ne!(a, c);
assert_ne!(
GuardedResource::ProjectContext,
GuardedResource::Memory(MemorySlug::new("x").unwrap())
);
}
}

203
crates/domain/src/input.rs Normal file
View File

@ -0,0 +1,203 @@
//! 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);
}
}

View File

@ -31,11 +31,15 @@
#![warn(missing_docs)]
pub mod agent;
pub mod conversation;
pub mod error;
pub mod events;
pub mod fileguard;
pub mod git;
pub mod ids;
pub mod input;
pub mod layout;
pub mod mailbox;
pub mod markdown;
pub mod memory;
pub mod orchestrator;
@ -72,6 +76,20 @@ pub use profile::{
AgentProfile, ContextInjection, EmbedderProfile, EmbedderStrategy, SessionStrategy,
};
pub use mailbox::{AgentMailbox, MailboxError, PendingReply, Ticket, TicketId};
pub use conversation::{
Conversation, ConversationError, ConversationId, ConversationParty, ConversationRegistry,
ConversationSession, SessionRef, WaitForGraph,
};
pub use input::{AgentBusyState, InputMediator, InputSource};
pub use fileguard::{
is_orchestrator, may_write_directly, FileGuard, GuardError, GuardedResource, ReadLease,
WriteLease,
};
pub use markdown::MarkdownDoc;
pub use memory::{Memory, MemoryFrontmatter, MemoryIndexEntry, MemoryLink, MemorySlug, MemoryType};

View File

@ -0,0 +1,330 @@
//! Inter-agent **mailbox** port (Option 1 « Terminal + MCP », lot B-1).
//!
//! A delegating agent calls `idea_ask_agent(target, task)` and **blocks** until the
//! target renders a result via `idea_reply(result)`. Between those two MCP calls,
//! IdeA must (1) hand the task to the target's single FIFO input and (2) hold the
//! caller's await on a reply slot that the target's later `idea_reply` resolves.
//!
//! This module owns the **pure** contract of that rendezvous: the [`AgentMailbox`]
//! port plus its value objects ([`Ticket`], [`TicketId`], [`MailboxError`]) and the
//! [`PendingReply`] handle the caller awaits. It is **I/O-free**: the actual queue,
//! its mutex and the one-shot reply channel are an infrastructure concern (the
//! `InMemoryMailbox` adapter). Keeping the contract here means the FIFO/resolution
//! invariants are expressed against a port the application layer depends on, never
//! against a concrete channel type (hexagonal + DIP).
//!
//! ## Model
//!
//! - **One queue per target agent** (`AgentId`). `enqueue` appends a [`Ticket`] and
//! returns a [`PendingReply`] the caller awaits.
//! - **`resolve(agent, result)` corrèle implicitement** the result with the ticket
//! at the **head** of that agent's queue — the agent is processing one task at a
//! time (1 agent = 1 employee, FIFO input), so its current `idea_reply` answers
//! the task it is currently working on. No ticket id is exposed to the model.
//! - **Timeout** is the caller's concern: it drops its [`PendingReply`] and calls
//! [`AgentMailbox::cancel_head`] to retire the stuck head ticket so the queue
//! advances.
use std::future::Future;
use std::pin::Pin;
use std::task::{Context, Poll};
use crate::conversation::ConversationId;
use crate::ids::AgentId;
use crate::input::InputSource;
/// Identifies one queued [`Ticket`] within a target agent's mailbox.
///
/// Newtype around [`uuid::Uuid`]; minted by the adapter on `enqueue`. It is **never
/// exposed to the model** (the MCP `idea_reply` schema carries only `result`):
/// correlation is positional (head of the FIFO), the id only lets the caller name
/// the exact ticket it wants retired on timeout ([`AgentMailbox::cancel_head`]).
#[derive(
Debug,
Clone,
Copy,
PartialEq,
Eq,
Hash,
PartialOrd,
Ord,
serde::Serialize,
serde::Deserialize,
)]
#[serde(transparent)]
pub struct TicketId(pub uuid::Uuid);
impl TicketId {
/// Wraps an existing [`uuid::Uuid`].
#[must_use]
pub const fn from_uuid(id: uuid::Uuid) -> Self {
Self(id)
}
/// Mints a fresh random ticket id.
#[must_use]
pub fn new_random() -> Self {
Self(uuid::Uuid::new_v4())
}
/// Returns the inner [`uuid::Uuid`].
#[must_use]
pub const fn as_uuid(&self) -> uuid::Uuid {
self.0
}
}
impl std::fmt::Display for TicketId {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "{}", self.0)
}
}
/// A delegation request queued for a target agent: who asked, and what for.
///
/// The `id` lets the caller retire **this** ticket on timeout; `requester` and
/// `task` are carried so the application layer can prefix the task with the asking
/// agent's identity when it writes to the target's input (`[IdeA · tâche de A ·
/// ticket …]`). Pure value object (no behaviour, no I/O).
///
/// **Origin & target** (cadrage C1): a ticket also carries its [`InputSource`]
/// (Human or Agent — the *source of truth* for the requester, the `requester`
/// string being a derived display label) and the [`ConversationId`] of the thread
/// it enters. These are added through **additive** constructors ([`Ticket::from_human`],
/// [`Ticket::from_agent`]); [`Ticket::new`] keeps its signature (Open/Closed) and
/// defaults to a `Human` source with a nil conversation for back-compat.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Ticket {
/// Stable id of this queued request (minted by the adapter on `enqueue`).
pub id: TicketId,
/// The origin of this input (Human or a delegating Agent).
pub source: InputSource,
/// The conversation thread this task enters.
pub conversation: ConversationId,
/// Display name (or id) of the agent that issued the `idea_ask_agent`.
pub requester: String,
/// The task/message to deliver to the target agent.
pub task: String,
}
impl Ticket {
/// Builds a ticket from its parts (back-compat: `Human` source, nil conversation).
///
/// Preserved for existing call sites. Prefer [`Ticket::from_human`] /
/// [`Ticket::from_agent`] when the source and conversation are known.
#[must_use]
pub fn new(id: TicketId, requester: impl Into<String>, task: impl Into<String>) -> Self {
Self {
id,
source: InputSource::Human,
conversation: ConversationId::from_uuid(uuid::Uuid::nil()),
requester: requester.into(),
task: task.into(),
}
}
/// Builds a ticket originating from the **human** operator, for a given thread.
#[must_use]
pub fn from_human(
id: TicketId,
conversation: ConversationId,
requester: impl Into<String>,
task: impl Into<String>,
) -> Self {
Self {
id,
source: InputSource::Human,
conversation,
requester: requester.into(),
task: task.into(),
}
}
/// Builds a ticket originating from a delegating **agent**, for a given thread.
#[must_use]
pub fn from_agent(
id: TicketId,
source: AgentId,
conversation: ConversationId,
requester: impl Into<String>,
task: impl Into<String>,
) -> Self {
Self {
id,
source: InputSource::agent(source),
conversation,
requester: requester.into(),
task: task.into(),
}
}
}
/// Errors raised by the [`AgentMailbox`] port.
#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
pub enum MailboxError {
/// `resolve` was called for an agent whose queue is **empty** — an
/// `idea_reply` with no matching `idea_ask_agent` in flight. Surfaced as a typed
/// error (never a panic) so the offending agent can read it and adjust.
#[error("no pending request to reply to for agent {0}")]
NoPendingRequest(AgentId),
/// The awaited reply channel closed before a result arrived — the target's
/// session ended (or was killed) without rendering an `idea_reply`. The caller's
/// await resolves to this instead of hanging forever.
#[error("reply channel closed before a result was rendered")]
Cancelled,
}
/// A handle the caller awaits to receive a target agent's reply.
///
/// Returned by [`AgentMailbox::enqueue`]. Awaiting it yields the `String` result a
/// later [`AgentMailbox::resolve`] feeds into this ticket's slot, or
/// [`MailboxError::Cancelled`] if the reply channel closes first. The future is
/// **opaque** (the adapter builds it from its one-shot receiver): the domain stays
/// free of any concrete channel type, and the await point lives in the application
/// layer's `ask_agent`.
pub struct PendingReply {
inner: Pin<Box<dyn Future<Output = Result<String, MailboxError>> + Send>>,
}
impl PendingReply {
/// Wraps a reply future built by the adapter (e.g. over a one-shot receiver).
#[must_use]
pub fn new(
inner: Pin<Box<dyn Future<Output = Result<String, MailboxError>> + Send>>,
) -> Self {
Self { inner }
}
}
impl Future for PendingReply {
type Output = Result<String, MailboxError>;
fn poll(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Self::Output> {
self.inner.as_mut().poll(cx)
}
}
impl std::fmt::Debug for PendingReply {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("PendingReply").finish_non_exhaustive()
}
}
/// The inter-agent rendezvous port: a per-agent FIFO of delegation tickets, each
/// awaiting a one-shot reply (Option 1, lot B-1).
///
/// **Per-agent FIFO** (1 agent = 1 employee): tickets for the **same** target are
/// served head-first; tickets for **different** targets never block one another.
/// `enqueue` returns the [`PendingReply`] the caller awaits; the target's later
/// `idea_reply` lands in [`AgentMailbox::resolve`], which corrèle it with the head
/// of that agent's queue.
///
/// Kept object-safe (`&self`, owned/`Copy` args) so the application layer holds it
/// as `Arc<dyn AgentMailbox>`; the implementation owns all interior mutability.
pub trait AgentMailbox: Send + Sync {
/// Appends `ticket` to `agent`'s FIFO and returns the handle to await its reply.
///
/// The returned [`PendingReply`] resolves when a later [`AgentMailbox::resolve`]
/// feeds a result to this ticket (once it reaches the head and is answered), or
/// to [`MailboxError::Cancelled`] if the reply channel closes first.
fn enqueue(&self, agent: AgentId, ticket: Ticket) -> PendingReply;
/// Resolves the request at the **head** of `agent`'s FIFO with `result`,
/// waking its awaiting [`PendingReply`] and removing it from the queue.
///
/// Positional correlation: the agent processes one task at a time, so its
/// current `idea_reply` answers the head ticket.
///
/// # Errors
/// [`MailboxError::NoPendingRequest`] when `agent` has no queued ticket (an
/// `idea_reply` with no matching ask in flight).
fn resolve(&self, agent: AgentId, result: String) -> Result<(), MailboxError>;
/// Resolves the request identified by `ticket_id` **anywhere** in `agent`'s FIFO
/// with `result`, waking its awaiting [`PendingReply`] and removing it from the
/// queue (cadrage C3 §3.3 — deterministic, multi-thread correlation).
///
/// This is the **by-ticket** counterpart of [`AgentMailbox::resolve`]: when an
/// agent answers a delegation it echoes the ticket id from the `[IdeA · … ·
/// ticket <id>]` prefix, so IdeA correlates exactly that request even when the
/// agent has several threads in flight.
///
/// The default implementation falls back to [`AgentMailbox::resolve`] (head of
/// queue), so a mailbox that does not track ids stays correct for mono-thread
/// agents. The in-memory adapter overrides it with true id-keyed resolution.
///
/// # Errors
/// [`MailboxError::NoPendingRequest`] when no queued ticket matches `ticket_id`
/// (and, for the default, when `agent`'s queue is empty).
fn resolve_ticket(
&self,
agent: AgentId,
_ticket_id: TicketId,
result: String,
) -> Result<(), MailboxError> {
self.resolve(agent, result)
}
/// Retires the ticket `ticket_id` **iff** it is currently at the head of
/// `agent`'s FIFO (the caller timed out waiting for it), so the queue advances.
///
/// A no-op when the head is a different ticket (the timed-out one was already
/// resolved, or another caller's ticket is now in front) — idempotent and safe.
fn cancel_head(&self, agent: AgentId, ticket_id: TicketId);
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn ticket_carries_its_parts() {
let id = TicketId::new_random();
let t = Ticket::new(id, "Main", "do X");
assert_eq!(t.id, id);
assert_eq!(t.requester, "Main");
assert_eq!(t.task, "do X");
// Back-compat default: human source, nil conversation.
assert_eq!(t.source, InputSource::Human);
assert_eq!(
t.conversation,
ConversationId::from_uuid(uuid::Uuid::nil())
);
}
#[test]
fn from_human_carries_source_and_conversation() {
let conv = ConversationId::from_uuid(uuid::Uuid::from_u128(5));
let t = Ticket::from_human(TicketId::new_random(), conv, "User", "do X");
assert_eq!(t.source, InputSource::Human);
assert_eq!(t.conversation, conv);
assert_eq!(t.task, "do X");
}
#[test]
fn from_agent_carries_agent_source_and_conversation() {
let conv = ConversationId::from_uuid(uuid::Uuid::from_u128(6));
let from = AgentId::from_uuid(uuid::Uuid::from_u128(2));
let t = Ticket::from_agent(TicketId::new_random(), from, conv, "A", "delegate");
assert_eq!(t.source, InputSource::agent(from));
assert_eq!(t.source.as_agent(), Some(from));
assert_eq!(t.conversation, conv);
}
#[test]
fn ticket_id_roundtrips_through_uuid() {
let u = uuid::Uuid::from_u128(7);
let id = TicketId::from_uuid(u);
assert_eq!(id.as_uuid(), u);
assert_eq!(id.to_string(), u.to_string());
}
#[test]
fn mailbox_errors_are_distinct_and_typed() {
let a = AgentId::from_uuid(uuid::Uuid::from_u128(1));
assert_ne!(
MailboxError::NoPendingRequest(a),
MailboxError::Cancelled
);
}
}

View File

@ -13,7 +13,9 @@
use serde::{Deserialize, Serialize};
use crate::ids::NodeId;
use crate::conversation::ConversationParty;
use crate::ids::{AgentId, NodeId};
use crate::mailbox::TicketId;
use crate::skill::SkillScope;
/// Errors raised while validating a raw [`OrchestratorRequest`].
@ -46,6 +48,14 @@ pub enum OrchestratorError {
/// The offending visibility value.
visibility: String,
},
/// The emitting-agent id (`requestedBy`) for `agent.reply` is not a valid id.
#[error("invalid emitting-agent id `{value}` for action `{action}`")]
InvalidAgentId {
/// The action being validated.
action: String,
/// The offending id value.
value: String,
},
}
/// The raw, wire-level orchestrator request as deserialised from a request file.
@ -103,6 +113,25 @@ pub struct OrchestratorRequest {
/// other actions.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub scope: Option<String>,
/// Result body for `agent.reply` (`idea_reply`): the content the emitting agent
/// renders for the requester. Required by `agent.reply`, ignored otherwise.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub result: Option<String>,
/// Optional ticket id echoed by `agent.reply` (`idea_reply`): when present, the
/// reply correlates **by ticket** (deterministic, multi-thread); absent, it falls
/// back to the head of the emitting agent's queue (cadrage C3 §3.3). Ignored by
/// the other actions.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub ticket: Option<String>,
/// Markdown body for the FileGuard-mediated context/memory tools (`context.propose`,
/// `memory.write`, cadrage C7). Required by those actions, ignored otherwise.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub content: Option<String>,
/// Target memory note slug for the memory tools (`memory.read`/`memory.write`,
/// cadrage C7). Required by `memory.write`; optional for `memory.read` (absent ⇒
/// the aggregated index). Ignored by the other actions.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub slug: Option<String>,
}
/// A validated orchestrator command — the only thing the application layer acts on.
@ -149,6 +178,31 @@ pub enum OrchestratorCommand {
target: String,
/// The task/message to send and await a reply for.
task: String,
/// The **requesting** agent (handshake identity), when the ask originates
/// from another agent's `idea_ask_agent`. `None` for an ask with no carried
/// requester (e.g. a file-protocol request without `requestedBy`): the
/// orchestrator then routes it on the `User↔target` thread. This is what lets
/// `ask_agent` resolve the **A↔B** conversation and feed the wait-for graph
/// (cadrage C3 §5.2).
requester: Option<AgentId>,
},
/// The emitting agent renders the **result** of the task it is currently
/// processing (Option 1 « Terminal + MCP », `idea_reply`).
///
/// Correlation is **implicit and positional**: `from` is the emitting agent's id
/// (carried by the MCP handshake, never a model-managed value), so the result
/// resolves the ticket at the head of *that agent's* mailbox queue — the
/// delegation it is working on. No ticket id is exposed to the model.
Reply {
/// The emitting agent (handshake identity), whose request the result resolves.
from: AgentId,
/// The ticket the reply correlates to (echoed by the agent from the
/// `[IdeA · … · ticket <id>]` prefix). `Some` ⇒ correlate **by ticket**
/// (deterministic, multi-thread); `None` ⇒ fall back to the head of `from`'s
/// queue (compat mono-thread agents) (cadrage C3 §3.3).
ticket: Option<TicketId>,
/// The result content the requester awaits.
result: String,
},
/// List the project's agents (discovery) — exactly the data the UI reads from
/// the manifest. Carries no argument: the dispatch resolves the agents against
@ -163,6 +217,47 @@ pub enum OrchestratorCommand {
/// Scope the skill is created in (defaults to [`SkillScope::Project`]).
scope: SkillScope,
},
/// Read an IdeA-owned context `.md` under the [`crate::fileguard::FileGuard`]
/// (cadrage C7). `target` absent = the **global project context**; otherwise the
/// named agent's context. Reading is shared (N readers).
ReadContext {
/// Target agent display name; `None` ⇒ the global project context.
target: Option<String>,
/// The party that issued the read (handshake identity), so the guard knows
/// who acquires the read lease.
requester: ConversationParty,
},
/// Propose new content for a context `.md` under the
/// [`crate::fileguard::FileGuard`] (cadrage C7). For an **agent** context this is
/// a direct write under a write-lease; for the **global** project context it is a
/// *proposal* (the guard forbids a non-orchestrator direct write — the change is
/// materialised as a proposal for validation).
ProposeContext {
/// Target agent display name; `None` ⇒ the global project context.
target: Option<String>,
/// The proposed Markdown body.
content: String,
/// The proposing party (handshake identity).
requester: ConversationParty,
},
/// Read a memory note under the [`crate::fileguard::FileGuard`] (cadrage C7).
/// `slug` absent = the aggregated `MEMORY.md` index; otherwise one note.
ReadMemory {
/// Target note slug; `None` ⇒ the aggregated index.
slug: Option<String>,
/// The reading party (handshake identity).
requester: ConversationParty,
},
/// Write a memory note under the [`crate::fileguard::FileGuard`] (cadrage C7).
/// Memory is project-shared; written directly under a write-lease.
WriteMemory {
/// Target note slug (required).
slug: String,
/// The Markdown body to store.
content: String,
/// The writing party (handshake identity).
requester: ConversationParty,
},
}
/// Where IdeA should place a launched agent session.
@ -230,6 +325,30 @@ impl OrchestratorRequest {
"agent.message" => Ok(OrchestratorCommand::AskAgent {
target: self.require_target_agent(action)?,
task: self.require("task", action, self.task.as_deref())?,
requester: self.optional_requester(action)?,
}),
"agent.reply" => Ok(OrchestratorCommand::Reply {
from: self.require_from(action)?,
ticket: self.optional_ticket(action)?,
result: self.require("result", action, self.result.as_deref())?,
}),
"context.read" => Ok(OrchestratorCommand::ReadContext {
target: self.optional_target_agent(),
requester: self.requester_party(),
}),
"context.propose" => Ok(OrchestratorCommand::ProposeContext {
target: self.optional_target_agent(),
content: self.require("content", action, self.content.as_deref())?,
requester: self.requester_party(),
}),
"memory.read" => Ok(OrchestratorCommand::ReadMemory {
slug: self.optional_slug(),
requester: self.requester_party(),
}),
"memory.write" => Ok(OrchestratorCommand::WriteMemory {
slug: self.require("slug", action, self.slug.as_deref())?,
content: self.require("content", action, self.content.as_deref())?,
requester: self.requester_party(),
}),
"list_agents" | "agent.list" => Ok(OrchestratorCommand::ListAgents),
"create_skill" | "skill.create" => Ok(OrchestratorCommand::CreateSkill {
@ -284,6 +403,90 @@ impl OrchestratorRequest {
self.require("name", action, self.name.as_deref())
}
/// Parses the emitting agent id (`requestedBy`) for `agent.reply` into an
/// [`AgentId`]. The MCP server injects this from the loopback **handshake**
/// `requester` (never a model-managed value), so a well-formed reply always
/// carries it; a missing/blank one is a `MissingField`, a non-uuid one an
/// `InvalidAgentId`.
fn require_from(&self, action: &str) -> Result<AgentId, OrchestratorError> {
let raw = self.require("requestedBy", action, self.requested_by.as_deref())?;
uuid::Uuid::parse_str(&raw)
.map(AgentId::from_uuid)
.map_err(|_| OrchestratorError::InvalidAgentId {
action: action.to_owned(),
value: raw,
})
}
/// Parses the **optional** requester id (`requestedBy`) for `agent.message` into
/// an [`AgentId`]. The MCP handshake injects a real agent uuid here; the legacy
/// file protocol may carry a free-form **display name** instead. So this is
/// **lenient**: absent/blank/non-uuid ⇒ `None` (the ask carries no machine
/// requester and routes on the `User↔target` thread); a well-formed uuid ⇒
/// `Some(agent)`, enabling A↔B conversation routing + the wait-for guard.
#[allow(clippy::unnecessary_wraps, clippy::unused_self)]
fn optional_requester(&self, _action: &str) -> Result<Option<AgentId>, OrchestratorError> {
Ok(self
.requested_by
.as_deref()
.map(str::trim)
.filter(|s| !s.is_empty())
.and_then(|raw| uuid::Uuid::parse_str(raw).ok())
.map(AgentId::from_uuid))
}
/// Parses the **optional** echoed ticket id for `agent.reply` into a [`TicketId`].
/// Absent/blank ⇒ `None` (head-of-queue fallback); present-but-non-uuid ⇒
/// [`OrchestratorError::InvalidAgentId`] (reused as a generic invalid-id error).
fn optional_ticket(&self, action: &str) -> Result<Option<TicketId>, OrchestratorError> {
match self.ticket.as_deref().map(str::trim) {
None | Some("") => Ok(None),
Some(raw) => uuid::Uuid::parse_str(raw)
.map(|u| Some(TicketId::from_uuid(u)))
.map_err(|_| OrchestratorError::InvalidAgentId {
action: action.to_owned(),
value: raw.to_owned(),
}),
}
}
/// The optional target agent name for the FileGuard context tools: `targetAgent`
/// (or legacy `name`), trimmed; absent/blank ⇒ `None` (the global project context).
fn optional_target_agent(&self) -> Option<String> {
self.target_agent
.as_deref()
.or(self.name.as_deref())
.map(str::trim)
.filter(|s| !s.is_empty())
.map(str::to_owned)
}
/// The optional memory slug for `memory.read`: trimmed; absent/blank ⇒ `None`
/// (the aggregated index).
fn optional_slug(&self) -> Option<String> {
self.slug
.as_deref()
.map(str::trim)
.filter(|s| !s.is_empty())
.map(str::to_owned)
}
/// The party that issued a FileGuard-mediated request, derived from `requestedBy`.
/// A well-formed agent uuid (the MCP handshake path) ⇒ [`ConversationParty::Agent`];
/// anything else (absent/blank/non-uuid, e.g. the orchestrator's own file-protocol
/// path) ⇒ [`ConversationParty::User`] — IdeA's single orchestrator identity, the
/// only party allowed to write the global project context directly.
fn requester_party(&self) -> ConversationParty {
self.requested_by
.as_deref()
.map(str::trim)
.filter(|s| !s.is_empty())
.and_then(|raw| uuid::Uuid::parse_str(raw).ok())
.map_or(ConversationParty::User, |u| {
ConversationParty::agent(AgentId::from_uuid(u))
})
}
fn require_target_agent(&self, action: &str) -> Result<String, OrchestratorError> {
self.require(
"targetAgent",
@ -447,6 +650,56 @@ mod tests {
OrchestratorCommand::AskAgent {
target: "Architect".to_owned(),
task: "Analyse §17".to_owned(),
// `requestedBy: "Main"` is a display name, not a uuid ⇒ lenient `None`.
requester: None,
}
);
}
/// A well-formed uuid `requestedBy` is parsed into `Some(agent)` (the MCP
/// handshake path), enabling A↔B conversation routing.
#[test]
fn agent_message_carries_uuid_requester() {
let uid = uuid::Uuid::from_u128(7);
let r = req(&format!(
r#"{{ "type":"agent.message", "requestedBy":"{uid}", "targetAgent":"B", "task":"go" }}"#
));
assert_eq!(
r.validate().unwrap(),
OrchestratorCommand::AskAgent {
target: "B".to_owned(),
task: "go".to_owned(),
requester: Some(AgentId::from_uuid(uid)),
}
);
}
/// `agent.reply` correlates by ticket when the agent echoes it.
#[test]
fn agent_reply_carries_optional_ticket() {
let from = uuid::Uuid::from_u128(3);
let tkt = uuid::Uuid::from_u128(99);
let r = req(&format!(
r#"{{ "type":"agent.reply", "requestedBy":"{from}", "ticket":"{tkt}", "result":"done" }}"#
));
assert_eq!(
r.validate().unwrap(),
OrchestratorCommand::Reply {
from: AgentId::from_uuid(from),
ticket: Some(TicketId::from_uuid(tkt)),
result: "done".to_owned(),
}
);
// Without a ticket ⇒ None (head-of-queue fallback).
let r2 = req(&format!(
r#"{{ "type":"agent.reply", "requestedBy":"{from}", "result":"done" }}"#
));
assert_eq!(
r2.validate().unwrap(),
OrchestratorCommand::Reply {
from: AgentId::from_uuid(from),
ticket: None,
result: "done".to_owned(),
}
);
}
@ -575,6 +828,102 @@ mod tests {
);
}
#[test]
fn context_read_global_without_target_is_user_party() {
let r = req(r#"{ "type": "context.read" }"#);
assert_eq!(
r.validate().unwrap(),
OrchestratorCommand::ReadContext {
target: None,
requester: ConversationParty::User,
}
);
}
#[test]
fn context_read_with_uuid_requester_is_agent_party() {
let uid = uuid::Uuid::from_u128(5);
let r = req(&format!(
r#"{{ "type":"context.read", "requestedBy":"{uid}", "targetAgent":"Dev" }}"#
));
assert_eq!(
r.validate().unwrap(),
OrchestratorCommand::ReadContext {
target: Some("Dev".to_owned()),
requester: ConversationParty::agent(AgentId::from_uuid(uid)),
}
);
}
#[test]
fn context_propose_requires_content() {
let ok = req(r##"{ "type":"context.propose", "targetAgent":"Dev", "content":"# body" }"##);
assert_eq!(
ok.validate().unwrap(),
OrchestratorCommand::ProposeContext {
target: Some("Dev".to_owned()),
content: "# body".to_owned(),
requester: ConversationParty::User,
}
);
let missing = req(r#"{ "type":"context.propose", "targetAgent":"Dev" }"#);
assert_eq!(
missing.validate(),
Err(OrchestratorError::MissingField {
action: "context.propose".to_owned(),
field: "content".to_owned(),
})
);
}
#[test]
fn memory_read_optional_slug() {
assert_eq!(
req(r#"{ "type":"memory.read" }"#).validate().unwrap(),
OrchestratorCommand::ReadMemory {
slug: None,
requester: ConversationParty::User,
}
);
assert_eq!(
req(r#"{ "type":"memory.read", "slug":"note-a" }"#)
.validate()
.unwrap(),
OrchestratorCommand::ReadMemory {
slug: Some("note-a".to_owned()),
requester: ConversationParty::User,
}
);
}
#[test]
fn memory_write_requires_slug_and_content() {
assert_eq!(
req(r#"{ "type":"memory.write", "slug":"note-a", "content":"hi" }"#)
.validate()
.unwrap(),
OrchestratorCommand::WriteMemory {
slug: "note-a".to_owned(),
content: "hi".to_owned(),
requester: ConversationParty::User,
}
);
assert_eq!(
req(r#"{ "type":"memory.write", "content":"hi" }"#).validate(),
Err(OrchestratorError::MissingField {
action: "memory.write".to_owned(),
field: "slug".to_owned(),
})
);
assert_eq!(
req(r#"{ "type":"memory.write", "slug":"note-a" }"#).validate(),
Err(OrchestratorError::MissingField {
action: "memory.write".to_owned(),
field: "content".to_owned(),
})
);
}
#[test]
fn unknown_action_is_rejected() {
let r = req(r#"{ "action": "delete_everything", "name": "a" }"#);

View File

@ -283,6 +283,30 @@ pub struct AgentProfile {
/// sérialisation : un profil sans MCP sérialise exactement comme avant.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub mcp: Option<McpCapability>,
/// Motif de **retour-de-prompt** (cadrage « conversation par paire » §6, lot C5).
///
/// Donnée **déclarative** (pas de code par CLI — Open/Closed) : un motif **littéral**
/// recherché par sous-chaîne dans le flux de sortie PTY du tour courant. Quand il
/// apparaît, IdeA considère l'agent **revenu au prompt** et le marque `Idle`
/// (`InputMediator::mark_idle`), ce qui fait avancer sa file. C'est l'un des deux
/// signaux du « double signal OR » (l'autre étant un `idea_reply` explicite) ; le
/// **premier** des deux qui arrive libère le tour.
///
/// **Choix tranché : littéral, pas regex.** Une sous-chaîne littérale est
/// déterministe, sans dépendance (`regex`), suffisante pour un sigil de prompt
/// stable (ex. `"\n> "`), et ne risque pas le faux-positif d'un motif regex mal
/// échappé présent dans la sortie. Un moteur regex pourra être ajouté plus tard
/// comme variante déclarative (Open/Closed) si le besoin se confirme.
///
/// `None` (défaut, et valeur des profils existants) ⇒ **aucune** détection par
/// motif : l'agent ne repasse `Idle` que sur signal explicite (`idea_reply`) ou via
/// le garde-fou du timeout par tour. Conforme au fallback « en cas de doute → reste
/// `Busy` mais la file continue d'accepter » (jamais de faux `Idle`).
///
/// `skip_serializing_if = Option::is_none` ⇒ **zéro régression** de sérialisation :
/// un profil sans motif sérialise exactement comme avant.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub prompt_ready_pattern: Option<String>,
}
/// Embedding strategy of an [`EmbedderProfile`] (LOT C, étage 2 vectoriel).
@ -418,6 +442,7 @@ impl AgentProfile {
session,
structured_adapter: None,
mcp: None,
prompt_ready_pattern: None,
})
}
@ -439,6 +464,15 @@ impl AgentProfile {
self
}
/// Builder : fixe le motif de **retour-de-prompt** (§6, lot C5) et renvoie le
/// profil. Laisse [`AgentProfile::new`] stable (zéro régression d'appel) : les
/// profils sans détection de prompt ne l'appellent simplement pas.
#[must_use]
pub fn with_prompt_ready_pattern(mut self, pattern: impl Into<String>) -> Self {
self.prompt_ready_pattern = Some(pattern.into());
self
}
/// Indique si ce profil peut être **proposé à la sélection/création** d'un
/// agent (§17.3, lot D7). Source **unique** de vérité : un profil n'est
/// sélectionnable que s'il porte un [`StructuredAdapter`], c'est-à-dire s'il
@ -625,4 +659,48 @@ mod mcp_tests {
let s = McpConfigStrategy::env("IDEA_MCP_SERVER").expect("valid");
assert_eq!(s, McpConfigStrategy::Env { var: "IDEA_MCP_SERVER".to_owned() });
}
// -- Lot C5 : prompt_ready_pattern (détection retour-de-prompt) --------------
#[test]
fn profile_default_has_no_prompt_ready_pattern() {
// Existing profiles (built via `new`) carry no pattern ⇒ no false Idle.
assert!(profile_without_mcp().prompt_ready_pattern.is_none());
}
#[test]
fn profile_without_prompt_pattern_omits_key_in_json() {
let json = serde_json::to_string(&profile_without_mcp()).expect("serialise");
assert!(
!json.contains("promptReadyPattern"),
"a profile without a prompt pattern must NOT serialise the key (zero regression); got: {json}"
);
}
#[test]
fn legacy_json_without_prompt_pattern_deserialises_to_none() {
let legacy = r#"{
"id": "00000000-0000-0000-0000-000000000000",
"name": "Dev",
"command": "claude",
"args": [],
"contextInjection": { "strategy": "conventionFile", "target": "CLAUDE.md" },
"detect": null,
"cwdTemplate": "{agentRunDir}"
}"#;
let profile: AgentProfile = serde_json::from_str(legacy).expect("legacy deserialise");
assert!(profile.prompt_ready_pattern.is_none());
}
#[test]
fn with_prompt_ready_pattern_sets_and_round_trips() {
let profile = profile_without_mcp().with_prompt_ready_pattern("\n> ");
assert_eq!(profile.prompt_ready_pattern.as_deref(), Some("\n> "));
let json = serde_json::to_string(&profile).expect("serialise");
assert!(json.contains("promptReadyPattern"), "key present: {json}");
let back: AgentProfile = serde_json::from_str(&json).expect("deserialise");
assert_eq!(profile, back);
assert_eq!(back.prompt_ready_pattern.as_deref(), Some("\n> "));
}
}