agent conversation fix
This commit is contained in:
@ -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"));
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user