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:
431
crates/domain/src/conversation.rs
Normal file
431
crates/domain/src/conversation.rs
Normal 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());
|
||||
}
|
||||
}
|
||||
@ -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.
|
||||
|
||||
236
crates/domain/src/fileguard.rs
Normal file
236
crates/domain/src/fileguard.rs
Normal 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
203
crates/domain/src/input.rs
Normal 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);
|
||||
}
|
||||
}
|
||||
@ -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};
|
||||
|
||||
330
crates/domain/src/mailbox.rs
Normal file
330
crates/domain/src/mailbox.rs
Normal 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
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -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" }"#);
|
||||
|
||||
@ -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> "));
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user