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

@ -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" }"#);