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:
@ -44,11 +44,15 @@ pub enum ToolMapError {
|
||||
}
|
||||
|
||||
/// Whether a successful call of `tool` carries an inline reply payload back to the
|
||||
/// caller: `idea_ask_agent` (the target's `Final`) and `idea_list_agents` (the
|
||||
/// agent list as a JSON array).
|
||||
/// caller: `idea_ask_agent` (the target's reply) and `idea_list_agents` (the agent
|
||||
/// list as a JSON array). `idea_reply` is an **ACK only** (the result is routed to
|
||||
/// the awaiting requester, not echoed inline), so it is **not** in this set.
|
||||
#[must_use]
|
||||
pub fn tool_returns_reply(tool: &str) -> bool {
|
||||
matches!(tool, "idea_ask_agent" | "idea_list_agents")
|
||||
matches!(
|
||||
tool,
|
||||
"idea_ask_agent" | "idea_list_agents" | "idea_context_read" | "idea_memory_read"
|
||||
)
|
||||
}
|
||||
|
||||
/// The full catalogue advertised on `tools/list`.
|
||||
@ -82,6 +86,23 @@ pub fn catalogue() -> Vec<ToolDef> {
|
||||
"additionalProperties": false
|
||||
}),
|
||||
},
|
||||
ToolDef {
|
||||
name: "idea_reply",
|
||||
description: "Render the result of the task you are currently processing (the one IdeA \
|
||||
delegated to you as `[IdeA · tâche … · ticket <id>]`). Call this — never \
|
||||
answer in plain text — so IdeA can hand your answer back to the agent that \
|
||||
asked. **Echo the `ticket` id** shown in that prefix so IdeA correlates your \
|
||||
reply exactly, even when you handle several requests.",
|
||||
input_schema: json!({
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"result": { "type": "string", "description": "The result/answer to deliver to the requester." },
|
||||
"ticket": { "type": "string", "description": "The ticket id from the `[IdeA · … · ticket <id>]` prefix you are answering. Optional, but strongly recommended when you handle more than one request." }
|
||||
},
|
||||
"required": ["result"],
|
||||
"additionalProperties": false
|
||||
}),
|
||||
},
|
||||
ToolDef {
|
||||
name: "idea_launch_agent",
|
||||
description: "Launch (or attach) an IdeA agent. Fire-and-forget: returns once IdeA \
|
||||
@ -124,6 +145,62 @@ pub fn catalogue() -> Vec<ToolDef> {
|
||||
"additionalProperties": false
|
||||
}),
|
||||
},
|
||||
ToolDef {
|
||||
name: "idea_context_read",
|
||||
description: "Read an IdeA-owned context (under IdeA's reader/writer file guard). \
|
||||
Omit `target` for the global project context, or pass an agent's display \
|
||||
name for that agent's `.md`. Reading is shared (never blocks other readers).",
|
||||
input_schema: json!({
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"target": { "type": "string", "description": "Agent display name. Omit for the global project context." }
|
||||
},
|
||||
"additionalProperties": false
|
||||
}),
|
||||
},
|
||||
ToolDef {
|
||||
name: "idea_context_propose",
|
||||
description: "Propose new Markdown content for an IdeA-owned context (under the file \
|
||||
guard). For an agent's `.md` this writes directly; for the **global** \
|
||||
project context it is only a *proposal* (the global context is \
|
||||
single-writer — reserved to the orchestrator — so your change is recorded \
|
||||
for validation, not applied).",
|
||||
input_schema: json!({
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"target": { "type": "string", "description": "Agent display name. Omit to target the global project context (proposal only)." },
|
||||
"content": { "type": "string", "description": "The proposed Markdown body." }
|
||||
},
|
||||
"required": ["content"],
|
||||
"additionalProperties": false
|
||||
}),
|
||||
},
|
||||
ToolDef {
|
||||
name: "idea_memory_read",
|
||||
description: "Read project memory (under the file guard). Omit `slug` for the aggregated \
|
||||
index, or pass a note slug for one note.",
|
||||
input_schema: json!({
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"slug": { "type": "string", "description": "Memory note slug. Omit for the aggregated index." }
|
||||
},
|
||||
"additionalProperties": false
|
||||
}),
|
||||
},
|
||||
ToolDef {
|
||||
name: "idea_memory_write",
|
||||
description: "Write (create or replace) a project memory note under the file guard. \
|
||||
Memory is shared across the project's agents.",
|
||||
input_schema: json!({
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"slug": { "type": "string", "description": "Memory note slug (kebab-case)." },
|
||||
"content": { "type": "string", "description": "The Markdown body to store." }
|
||||
},
|
||||
"required": ["slug", "content"],
|
||||
"additionalProperties": false
|
||||
}),
|
||||
},
|
||||
ToolDef {
|
||||
name: "idea_create_skill",
|
||||
description: "Create a reusable IdeA skill (Markdown) in the project or global scope.",
|
||||
@ -146,6 +223,10 @@ pub fn catalogue() -> Vec<ToolDef> {
|
||||
/// validation authority.
|
||||
///
|
||||
/// `arguments` is the raw object an MCP client passes under `params.arguments`.
|
||||
/// `requester` is the **connected peer's agent id** (from the loopback handshake,
|
||||
/// cadrage v5 §1.4): it is injected as the `from` of an `idea_reply` so correlation
|
||||
/// uses the handshake identity, never a model-managed value. Every other tool
|
||||
/// ignores it.
|
||||
///
|
||||
/// # Errors
|
||||
/// - [`ToolMapError::UnknownTool`] for a name outside [`catalogue`],
|
||||
@ -154,6 +235,7 @@ pub fn catalogue() -> Vec<ToolDef> {
|
||||
pub fn map_tool_call(
|
||||
name: &str,
|
||||
arguments: &Value,
|
||||
requester: &str,
|
||||
) -> Result<OrchestratorCommand, ToolMapError> {
|
||||
let args = arguments
|
||||
.as_object()
|
||||
@ -173,10 +255,25 @@ pub fn map_tool_call(
|
||||
},
|
||||
"idea_ask_agent" => OrchestratorRequest {
|
||||
request_type: Some("agent.message".to_owned()),
|
||||
// The **requester** is the connected peer's handshake identity (never a
|
||||
// tool argument), injected as `requestedBy` so `validate` can route the
|
||||
// ask on the A↔B conversation and feed the wait-for guard (cadrage C3).
|
||||
requested_by: Some(requester.to_owned()),
|
||||
target_agent: s("target"),
|
||||
task: s("task"),
|
||||
..base()
|
||||
},
|
||||
"idea_reply" => OrchestratorRequest {
|
||||
request_type: Some("agent.reply".to_owned()),
|
||||
// `from` is the handshake identity, not a tool argument: inject the peer's
|
||||
// requester id as `requestedBy` so `validate` builds `Reply { from, .. }`.
|
||||
requested_by: Some(requester.to_owned()),
|
||||
// The agent echoes the `ticket` it received in the `[IdeA · … · ticket
|
||||
// <id>]` prefix ⇒ correlate by ticket (multi-thread); optional (cadrage C3 §3.3).
|
||||
ticket: s("ticket"),
|
||||
result: s("result"),
|
||||
..base()
|
||||
},
|
||||
"idea_launch_agent" => OrchestratorRequest {
|
||||
request_type: Some("agent.run".to_owned()),
|
||||
target_agent: s("target"),
|
||||
@ -197,6 +294,33 @@ pub fn map_tool_call(
|
||||
context: s("context"),
|
||||
..base()
|
||||
},
|
||||
"idea_context_read" => OrchestratorRequest {
|
||||
request_type: Some("context.read".to_owned()),
|
||||
// The requester (handshake identity) drives the read lease's `who`.
|
||||
requested_by: Some(requester.to_owned()),
|
||||
target_agent: s("target"),
|
||||
..base()
|
||||
},
|
||||
"idea_context_propose" => OrchestratorRequest {
|
||||
request_type: Some("context.propose".to_owned()),
|
||||
requested_by: Some(requester.to_owned()),
|
||||
target_agent: s("target"),
|
||||
content: s("content"),
|
||||
..base()
|
||||
},
|
||||
"idea_memory_read" => OrchestratorRequest {
|
||||
request_type: Some("memory.read".to_owned()),
|
||||
requested_by: Some(requester.to_owned()),
|
||||
slug: s("slug"),
|
||||
..base()
|
||||
},
|
||||
"idea_memory_write" => OrchestratorRequest {
|
||||
request_type: Some("memory.write".to_owned()),
|
||||
requested_by: Some(requester.to_owned()),
|
||||
slug: s("slug"),
|
||||
content: s("content"),
|
||||
..base()
|
||||
},
|
||||
"idea_create_skill" => OrchestratorRequest {
|
||||
request_type: Some("skill.create".to_owned()),
|
||||
name: s("name"),
|
||||
@ -225,6 +349,10 @@ fn base() -> OrchestratorRequest {
|
||||
node_id: None,
|
||||
attach_to_cell: None,
|
||||
scope: None,
|
||||
result: None,
|
||||
ticket: None,
|
||||
content: None,
|
||||
slug: None,
|
||||
}
|
||||
}
|
||||
|
||||
@ -241,9 +369,17 @@ mod tests {
|
||||
use super::*;
|
||||
use domain::OrchestratorVisibility;
|
||||
|
||||
/// A well-formed handshake requester id for the tests (parsed as an `AgentId`).
|
||||
const REQ: &str = "11111111-1111-1111-1111-111111111111";
|
||||
|
||||
/// Maps a tool call with an empty requester (the default for tools that ignore it).
|
||||
fn map(name: &str, args: &Value) -> Result<OrchestratorCommand, ToolMapError> {
|
||||
map_tool_call(name, args, "")
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ask_agent_maps_to_ask_command() {
|
||||
let cmd = map_tool_call(
|
||||
let cmd = map(
|
||||
"idea_ask_agent",
|
||||
&json!({ "target": "Architect", "task": "Analyse §17" }),
|
||||
)
|
||||
@ -253,13 +389,15 @@ mod tests {
|
||||
OrchestratorCommand::AskAgent {
|
||||
target: "Architect".to_owned(),
|
||||
task: "Analyse §17".to_owned(),
|
||||
// `map` passes an empty requester ⇒ no machine requester carried.
|
||||
requester: None,
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn launch_agent_maps_to_spawn_background_by_default() {
|
||||
let cmd = map_tool_call("idea_launch_agent", &json!({ "target": "Dev" })).unwrap();
|
||||
let cmd = map("idea_launch_agent", &json!({ "target": "Dev" })).unwrap();
|
||||
assert_eq!(
|
||||
cmd,
|
||||
OrchestratorCommand::SpawnAgent {
|
||||
@ -274,11 +412,11 @@ mod tests {
|
||||
#[test]
|
||||
fn stop_update_and_skill_map_to_their_commands() {
|
||||
assert_eq!(
|
||||
map_tool_call("idea_stop_agent", &json!({ "target": "Dev" })).unwrap(),
|
||||
map("idea_stop_agent", &json!({ "target": "Dev" })).unwrap(),
|
||||
OrchestratorCommand::StopAgent { name: "Dev".to_owned() }
|
||||
);
|
||||
assert_eq!(
|
||||
map_tool_call(
|
||||
map(
|
||||
"idea_update_context",
|
||||
&json!({ "target": "Dev", "context": "# body" })
|
||||
)
|
||||
@ -289,7 +427,7 @@ mod tests {
|
||||
}
|
||||
);
|
||||
assert_eq!(
|
||||
map_tool_call(
|
||||
map(
|
||||
"idea_create_skill",
|
||||
&json!({ "name": "deploy", "context": "# steps" })
|
||||
)
|
||||
@ -304,19 +442,19 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn missing_required_field_surfaces_validation_error() {
|
||||
let err = map_tool_call("idea_ask_agent", &json!({ "target": "Architect" }));
|
||||
let err = map("idea_ask_agent", &json!({ "target": "Architect" }));
|
||||
assert!(matches!(err, Err(ToolMapError::Invalid(_))));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn list_agents_maps_to_list_command() {
|
||||
let cmd = map_tool_call("idea_list_agents", &json!({})).unwrap();
|
||||
let cmd = map("idea_list_agents", &json!({})).unwrap();
|
||||
assert_eq!(cmd, OrchestratorCommand::ListAgents);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unknown_tool_is_rejected() {
|
||||
let err = map_tool_call("idea_made_up_tool", &json!({}));
|
||||
let err = map("idea_made_up_tool", &json!({}));
|
||||
assert_eq!(
|
||||
err,
|
||||
Err(ToolMapError::UnknownTool("idea_made_up_tool".to_owned()))
|
||||
@ -325,10 +463,155 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn non_object_arguments_are_rejected() {
|
||||
let err = map_tool_call("idea_ask_agent", &json!("nope"));
|
||||
let err = map("idea_ask_agent", &json!("nope"));
|
||||
assert_eq!(
|
||||
err,
|
||||
Err(ToolMapError::BadArguments("idea_ask_agent".to_owned()))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reply_maps_with_handshake_requester_as_from() {
|
||||
// `from` comes from the handshake requester, NOT a tool argument.
|
||||
let cmd = map_tool_call("idea_reply", &json!({ "result": "the answer" }), REQ).unwrap();
|
||||
assert_eq!(
|
||||
cmd,
|
||||
OrchestratorCommand::Reply {
|
||||
from: domain::AgentId::from_uuid(uuid::Uuid::parse_str(REQ).unwrap()),
|
||||
ticket: None,
|
||||
result: "the answer".to_owned(),
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reply_echoes_ticket_when_provided() {
|
||||
let tkt = "22222222-2222-2222-2222-222222222222";
|
||||
let cmd = map_tool_call(
|
||||
"idea_reply",
|
||||
&json!({ "result": "done", "ticket": tkt }),
|
||||
REQ,
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
cmd,
|
||||
OrchestratorCommand::Reply {
|
||||
from: domain::AgentId::from_uuid(uuid::Uuid::parse_str(REQ).unwrap()),
|
||||
ticket: Some(domain::mailbox::TicketId::from_uuid(
|
||||
uuid::Uuid::parse_str(tkt).unwrap()
|
||||
)),
|
||||
result: "done".to_owned(),
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ask_agent_injects_handshake_requester() {
|
||||
let cmd = map_tool_call(
|
||||
"idea_ask_agent",
|
||||
&json!({ "target": "B", "task": "go" }),
|
||||
REQ,
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
cmd,
|
||||
OrchestratorCommand::AskAgent {
|
||||
target: "B".to_owned(),
|
||||
task: "go".to_owned(),
|
||||
requester: Some(domain::AgentId::from_uuid(
|
||||
uuid::Uuid::parse_str(REQ).unwrap()
|
||||
)),
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reply_without_result_is_a_validation_error() {
|
||||
let err = map_tool_call("idea_reply", &json!({}), REQ);
|
||||
assert!(matches!(err, Err(ToolMapError::Invalid(_))));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reply_with_blank_or_missing_requester_is_a_validation_error() {
|
||||
// No handshake identity ⇒ no `from` ⇒ typed validation error (never a panic).
|
||||
let err = map_tool_call("idea_reply", &json!({ "result": "x" }), "");
|
||||
assert!(matches!(err, Err(ToolMapError::Invalid(_))));
|
||||
// A non-uuid requester is rejected too.
|
||||
let err2 = map_tool_call("idea_reply", &json!({ "result": "x" }), "not-a-uuid");
|
||||
assert!(matches!(err2, Err(ToolMapError::Invalid(_))));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn context_read_maps_with_requester_party() {
|
||||
// Global (no target) with a uuid requester ⇒ Agent party.
|
||||
let cmd = map_tool_call("idea_context_read", &json!({}), REQ).unwrap();
|
||||
assert_eq!(
|
||||
cmd,
|
||||
OrchestratorCommand::ReadContext {
|
||||
target: None,
|
||||
requester: domain::ConversationParty::agent(domain::AgentId::from_uuid(
|
||||
uuid::Uuid::parse_str(REQ).unwrap()
|
||||
)),
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn context_propose_requires_content() {
|
||||
let ok = map_tool_call(
|
||||
"idea_context_propose",
|
||||
&json!({ "target": "Dev", "content": "# body" }),
|
||||
REQ,
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
ok,
|
||||
OrchestratorCommand::ProposeContext {
|
||||
target: Some("Dev".to_owned()),
|
||||
content: "# body".to_owned(),
|
||||
requester: domain::ConversationParty::agent(domain::AgentId::from_uuid(
|
||||
uuid::Uuid::parse_str(REQ).unwrap()
|
||||
)),
|
||||
}
|
||||
);
|
||||
let err = map_tool_call("idea_context_propose", &json!({ "target": "Dev" }), REQ);
|
||||
assert!(matches!(err, Err(ToolMapError::Invalid(_))));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn memory_read_and_write_map_to_their_commands() {
|
||||
assert_eq!(
|
||||
map_tool_call("idea_memory_read", &json!({ "slug": "note-a" }), REQ).unwrap(),
|
||||
OrchestratorCommand::ReadMemory {
|
||||
slug: Some("note-a".to_owned()),
|
||||
requester: domain::ConversationParty::agent(domain::AgentId::from_uuid(
|
||||
uuid::Uuid::parse_str(REQ).unwrap()
|
||||
)),
|
||||
}
|
||||
);
|
||||
assert_eq!(
|
||||
map_tool_call(
|
||||
"idea_memory_write",
|
||||
&json!({ "slug": "note-a", "content": "hi" }),
|
||||
REQ
|
||||
)
|
||||
.unwrap(),
|
||||
OrchestratorCommand::WriteMemory {
|
||||
slug: "note-a".to_owned(),
|
||||
content: "hi".to_owned(),
|
||||
requester: domain::ConversationParty::agent(domain::AgentId::from_uuid(
|
||||
uuid::Uuid::parse_str(REQ).unwrap()
|
||||
)),
|
||||
}
|
||||
);
|
||||
// memory.write without content ⇒ validation error.
|
||||
let err = map_tool_call("idea_memory_write", &json!({ "slug": "note-a" }), REQ);
|
||||
assert!(matches!(err, Err(ToolMapError::Invalid(_))));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reply_is_not_an_inline_reply_tool() {
|
||||
// ACK only: the result is routed to the awaiting requester, not echoed.
|
||||
assert!(!tool_returns_reply("idea_reply"));
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user