feat(agent): messagerie inter-agents via send_blocking (D6) — §17
Comble le trou historique : un agent qui en interroge un autre reçoit enfin
le CONTENU de la réponse, plus seulement un ACK de cycle de vie.
- domain : OrchestratorCommand::AskAgent { target, task } + action wire
agent.message (target+task requis) ; DomainEvent::AgentReplied
{ agent_id, reply_len } (bus I/O-free, le contenu remonte par l'outcome).
- application : OrchestratorOutcome gagne reply: Option<String>. Méthode
ask_agent (§17.4) — cible structurée vivante ⇒ send_blocking direct ;
cible morte ⇒ LaunchAgent structuré puis send ; agent vivant en PTY ou
profil sans structured_adapter ⇒ Invalid (non adressable, jamais d'ACK
trompeur) ; agent inconnu ⇒ NotFound ; service non câblé ⇒ Invalid.
Timeout (300s) ⇒ erreur typée SANS tuer la session. Succès ⇒ publie
AgentReplied + reply: Some(content). Injection additive via builders
with_structured / with_events (call sites legacy intacts).
- app-tauri : state câble with_structured/with_events ; DTO AgentReplied.
Tests (QA) : +14 verts (11 app + 3 domaine), invariants validés par
mutation test (reply!=None, timeout ne shutdown pas). cargo test
--workspace : 817 passed, 0 failed.
Suivi (D6b) : surfacer reply dans le writer wire .response.json
(infrastructure/orchestrator) pour la délégation par protocole fichier.
Reste D7 : menu restreint Claude/Codex + retrait custom.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@ -25,6 +25,18 @@ pub enum DomainEvent {
|
||||
/// The session it runs in.
|
||||
session_id: SessionId,
|
||||
},
|
||||
/// A target agent produced a synchronous reply to an inter-agent `ask`
|
||||
/// (ARCHITECTURE §17.4): the requester sent a task via `agent.message`, IdeA
|
||||
/// drove the target's structured session to its turn `Final`, and this is the
|
||||
/// observability beacon for that completed rendezvous. Carries the target id and
|
||||
/// the reply size (a lightweight metric); the full body is returned to the
|
||||
/// requester out-of-band, not in the event payload (the bus stays I/O-free).
|
||||
AgentReplied {
|
||||
/// The agent that produced the reply.
|
||||
agent_id: AgentId,
|
||||
/// Number of bytes in the reply content (preview metric, not the payload).
|
||||
reply_len: usize,
|
||||
},
|
||||
/// An agent's process exited.
|
||||
AgentExited {
|
||||
/// The agent.
|
||||
|
||||
@ -60,8 +60,9 @@ pub struct OrchestratorRequest {
|
||||
/// Legacy v1 action (`spawn_agent`, `stop_agent`, `update_agent_context`).
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub action: Option<String>,
|
||||
/// V2 action type (`agent.run`, `agent.stop`, `agent.update_context`,
|
||||
/// `skill.create`). `type` is reserved in Rust, so the field is renamed.
|
||||
/// V2 action type (`agent.run`, `agent.stop`, `agent.message`,
|
||||
/// `agent.update_context`, `skill.create`). `type` is reserved in Rust, so the
|
||||
/// field is renamed.
|
||||
#[serde(default, rename = "type", skip_serializing_if = "Option::is_none")]
|
||||
pub request_type: Option<String>,
|
||||
/// Optional requester id/name, informational at this layer.
|
||||
@ -81,7 +82,8 @@ pub struct OrchestratorRequest {
|
||||
/// `create_skill` this carries the **new Markdown body** to write.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub context: Option<String>,
|
||||
/// Optional task/message carried by `agent.run` / future `agent.message`.
|
||||
/// Task/message carried by `agent.run` (fire-and-forget) and required by
|
||||
/// `agent.message` (synchronous ask: the prompt sent to the target agent).
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub task: Option<String>,
|
||||
/// Whether a spawned agent should stay in the background or attach to a cell.
|
||||
@ -135,6 +137,19 @@ pub enum OrchestratorCommand {
|
||||
/// New Markdown body.
|
||||
context: String,
|
||||
},
|
||||
/// Ask a target agent a question/task and **wait for its reply**.
|
||||
///
|
||||
/// Unlike [`Self::SpawnAgent`] (fire-and-forget lifecycle), this is the
|
||||
/// synchronous inter-agent rendezvous (ARCHITECTURE §17.4): the application
|
||||
/// layer drives the target's structured [`crate::ports::AgentSession`], waits
|
||||
/// for the turn's `Final`, and returns its content to the requester. The
|
||||
/// target is launched in structured mode first if it is not already live.
|
||||
AskAgent {
|
||||
/// Target agent display name (resolved case-insensitively).
|
||||
target: String,
|
||||
/// The task/message to send and await a reply for.
|
||||
task: String,
|
||||
},
|
||||
/// Create a reusable skill in the given scope — exactly as the UI would.
|
||||
CreateSkill {
|
||||
/// Display name (also the `.md` stem on disk).
|
||||
@ -208,6 +223,10 @@ impl OrchestratorRequest {
|
||||
name: self.require_target_agent(action)?,
|
||||
context: self.require("context", action, self.context.as_deref())?,
|
||||
}),
|
||||
"agent.message" => Ok(OrchestratorCommand::AskAgent {
|
||||
target: self.require_target_agent(action)?,
|
||||
task: self.require("task", action, self.task.as_deref())?,
|
||||
}),
|
||||
"create_skill" | "skill.create" => Ok(OrchestratorCommand::CreateSkill {
|
||||
name: self.require_name(action)?,
|
||||
content: self.require("context", action, self.context.as_deref())?,
|
||||
@ -413,6 +432,44 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn agent_message_validates_target_and_task() {
|
||||
let r = req(
|
||||
r#"{ "type": "agent.message", "requestedBy": "Main", "targetAgent": "Architect", "task": "Analyse §17" }"#,
|
||||
);
|
||||
assert_eq!(
|
||||
r.validate().unwrap(),
|
||||
OrchestratorCommand::AskAgent {
|
||||
target: "Architect".to_owned(),
|
||||
task: "Analyse §17".to_owned(),
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn agent_message_missing_task_is_rejected() {
|
||||
let r = req(r#"{ "type": "agent.message", "targetAgent": "Architect" }"#);
|
||||
assert_eq!(
|
||||
r.validate(),
|
||||
Err(OrchestratorError::MissingField {
|
||||
action: "agent.message".to_owned(),
|
||||
field: "task".to_owned(),
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn agent_message_missing_target_is_rejected() {
|
||||
let r = req(r#"{ "type": "agent.message", "task": "do it" }"#);
|
||||
assert_eq!(
|
||||
r.validate(),
|
||||
Err(OrchestratorError::MissingField {
|
||||
action: "agent.message".to_owned(),
|
||||
field: "targetAgent".to_owned(),
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stop_agent_validates() {
|
||||
let r = req(r#"{ "action": "stop_agent", "name": "dev-backend" }"#);
|
||||
|
||||
Reference in New Issue
Block a user