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:
@ -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" }"#);
|
||||
|
||||
Reference in New Issue
Block a user