agent conversation fix

This commit is contained in:
2026-06-27 12:42:37 +02:00
parent c2d1a669c5
commit 1fc7869160
38 changed files with 1173 additions and 1549 deletions

View File

@ -8,12 +8,17 @@
//! validation — the very same path the filesystem watcher takes. No tool
//! validates anything itself; no tool reaches a use case directly.
//!
//! ## Agent discovery (`idea_list_agents`)
//! ## Agent discovery (`idea_list_agents`) and delegation (`idea_ask_agent`)
//!
//! Discovery maps to the [`OrchestratorCommand::ListAgents`] domain variant and is
//! served through the **same** `OrchestratorService::dispatch` as every other tool
//! (no use-case is reached directly). Its success carries the agent list inline as
//! a JSON array in the outcome's reply — see [`tool_returns_reply`].
//!
//! Delegation maps to [`OrchestratorCommand::AskAgent`]. The MCP tool is only an
//! ergonomic IdeA entrypoint: the application orchestrator drives the target's
//! structured/headless session and returns the target turn's `Final` inline. The
//! target never receives a model-managed ticket and never has to call `idea_reply`.
use domain::{OrchestratorCommand, OrchestratorError, OrchestratorRequest};
use serde_json::{json, Value};
@ -44,9 +49,8 @@ pub enum ToolMapError {
}
/// Whether a successful call of `tool` carries an inline reply payload back to the
/// 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.
/// caller. `idea_ask_agent` is a synchronous delegation tool: its payload is the
/// target agent's captured final answer.
#[must_use]
pub fn tool_returns_reply(tool: &str) -> bool {
matches!(
@ -79,35 +83,20 @@ pub fn catalogue() -> Vec<ToolDef> {
},
ToolDef {
name: "idea_ask_agent",
description: "Ask another IdeA agent a task and wait for its reply (synchronous \
inter-agent rendezvous). Returns the target agent's answer inline.",
description: "Ask another IdeA agent to handle a task and wait for its final answer. \
IdeA launches or reattaches the target through its structured/headless \
profile, captures the target turn's Final, and returns it inline. The \
target does not call a reply tool or manage tickets.",
input_schema: json!({
"type": "object",
"properties": {
"target": { "type": "string", "description": "Target agent display name." },
"task": { "type": "string", "description": "The task/message to send." }
"task": { "type": "string", "description": "Task or question to send to the target agent." }
},
"required": ["target", "task"],
"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 \
@ -275,9 +264,9 @@ pub fn catalogue() -> Vec<ToolDef> {
///
/// `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.
/// cadrage v5 §1.4): `idea_ask_agent` carries it as `requestedBy` for A↔B routing
/// and cycle detection. Self-keyed tools also use it as their identity; the model
/// never supplies another agent's id.
///
/// # Errors
/// - [`ToolMapError::UnknownTool`] for a name outside [`catalogue`],
@ -303,27 +292,6 @@ pub fn map_tool_call(
request_type: Some("agent.list".to_owned()),
..base()
},
"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"),
@ -333,6 +301,13 @@ pub fn map_tool_call(
node_id: parse_node_id(args.get("nodeId")),
..base()
},
"idea_ask_agent" => OrchestratorRequest {
request_type: Some("agent.message".to_owned()),
requested_by: Some(requester.to_owned()),
target_agent: s("target"),
task: s("task"),
..base()
},
"idea_stop_agent" => OrchestratorRequest {
request_type: Some("agent.stop".to_owned()),
target_agent: s("target"),
@ -408,33 +383,7 @@ pub fn map_tool_call(
other => return Err(ToolMapError::UnknownTool(other.to_owned())),
};
let command = request.validate()?;
// Diagnostics mapping beacon (best-effort) pour les deux tools du rendezvous :
// `idea_ask_agent` et `idea_reply`. Longueurs et présence de ticket uniquement —
// jamais le corps `task`/`result` ni de secret. Permet de corréler un `[mcp]`
// begin avec la commande effectivement construite (kind/target/requester).
match name {
"idea_ask_agent" => application::diag!(
"[mcp] mapped kind=ask_agent target={} task_len={} requester={}",
s("target").as_deref().unwrap_or("-"),
s("task").map_or(0, |t| t.len()),
if requester.is_empty() { "-" } else { requester },
),
"idea_reply" => application::diag!(
"[mcp] mapped kind=reply result_len={} ticket={} requester={}",
s("result").map_or(0, |r| r.len()),
if s("ticket").is_some() {
"present"
} else {
"absent"
},
if requester.is_empty() { "-" } else { requester },
),
_ => {}
}
Ok(command)
request.validate().map_err(ToolMapError::Invalid)
}
/// An all-`None` request to spread over; keeps each arm above to just its fields.
@ -487,21 +436,39 @@ mod tests {
}
#[test]
fn ask_agent_maps_to_ask_command() {
let cmd = map(
"idea_ask_agent",
&json!({ "target": "Architect", "task": "Analyse §17" }),
)
.unwrap();
fn ask_agent_maps_to_headless_inter_agent_command_but_reply_stays_hidden() {
let requester = uuid::Uuid::from_u128(42).to_string();
assert_eq!(
cmd,
map_tool_call(
"idea_ask_agent",
&json!({ "target": "Architect", "task": "Analyse §17" }),
&requester,
)
.unwrap(),
OrchestratorCommand::AskAgent {
target: "Architect".to_owned(),
task: "Analyse §17".to_owned(),
// `map` passes an empty requester ⇒ no machine requester carried.
requester: None,
requester: Some(domain::AgentId::from_uuid(
uuid::Uuid::parse_str(&requester).unwrap()
)),
}
);
assert_eq!(
map_tool_call(
"idea_ask_agent",
&json!({ "target": "Architect", "task": "Analyse §17" }),
"",
),
Ok(OrchestratorCommand::AskAgent {
target: "Architect".to_owned(),
task: "Analyse §17".to_owned(),
requester: None,
})
);
assert_eq!(
map_tool_call("idea_reply", &json!({ "result": "the answer" }), REQ),
Err(ToolMapError::UnknownTool("idea_reply".to_owned()))
);
}
#[test]
@ -553,7 +520,7 @@ mod tests {
#[test]
fn missing_required_field_surfaces_validation_error() {
let err = map("idea_ask_agent", &json!({ "target": "Architect" }));
let err = map("idea_update_context", &json!({ "target": "Architect" }));
assert!(matches!(err, Err(ToolMapError::Invalid(_))));
}
@ -574,84 +541,13 @@ mod tests {
#[test]
fn non_object_arguments_are_rejected() {
let err = map("idea_ask_agent", &json!("nope"));
let err = map("idea_launch_agent", &json!("nope"));
assert_eq!(
err,
Err(ToolMapError::BadArguments("idea_ask_agent".to_owned()))
Err(ToolMapError::BadArguments("idea_launch_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.
@ -721,8 +617,8 @@ mod tests {
}
#[test]
fn reply_is_not_an_inline_reply_tool() {
// ACK only: the result is routed to the awaiting requester, not echoed.
fn ask_agent_is_an_inline_reply_tool_but_reply_is_not_exposed() {
assert!(tool_returns_reply("idea_ask_agent"));
assert!(!tool_returns_reply("idea_reply"));
}