Skip to content

Commit bda32d2

Browse files
committed
Add multi-agent communication graph
Directed links between agents with relationship semantics (peer/superior/subordinate), a shared instance-level database, and a send_agent_message tool that routes through the existing MessagingManager pipeline. Foundation for the 0.2.0 release. - Instance database (instance.db) with separate migration path for cross-agent data - AgentLink model with direction (one_way/two_way) and relationship policies - LinkStore CRUD backed by instance.db - [[links]] config section with startup sync - send_agent_message tool: resolves target, validates link, injects InboundMessage - ROLE.md identity file for operational responsibilities - Org context prompt fragment (superiors/subordinates/peers hierarchy) - Link context prompt fragment for agent-to-agent channels - AgentMessageSent/Received process events with SSE forwarding - Link CRUD API endpoints + topology snapshot endpoint
1 parent 329b3a6 commit bda32d2

24 files changed

Lines changed: 1442 additions & 7 deletions
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
CREATE TABLE IF NOT EXISTS agent_links (
2+
id TEXT PRIMARY KEY,
3+
from_agent_id TEXT NOT NULL,
4+
to_agent_id TEXT NOT NULL,
5+
direction TEXT NOT NULL DEFAULT 'two_way',
6+
relationship TEXT NOT NULL DEFAULT 'peer',
7+
enabled INTEGER NOT NULL DEFAULT 1,
8+
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
9+
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
10+
UNIQUE(from_agent_id, to_agent_id)
11+
);
12+
13+
CREATE INDEX IF NOT EXISTS idx_agent_links_from ON agent_links(from_agent_id);
14+
CREATE INDEX IF NOT EXISTS idx_agent_links_to ON agent_links(to_agent_id);

prompts/en/channel.md.j2

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -106,6 +106,14 @@ When in doubt, skip. Being a lurker who speaks when it matters is better than be
106106
{{ available_channels }}
107107
{%- endif %}
108108

109+
{%- if org_context %}
110+
{{ org_context }}
111+
{%- endif %}
112+
113+
{%- if link_context %}
114+
{{ link_context }}
115+
{%- endif %}
116+
109117
{%- if conversation_context %}
110118
## Conversation Context
111119

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
{%- if link_context %}
2+
## Agent Communication
3+
4+
This is an internal channel with **{{ link_context.agent_name }}** ({{ link_context.relationship }}).
5+
{% if link_context.relationship == "superior" -%}
6+
Messages from this agent carry organizational authority. Treat directives as assignments.
7+
{%- elif link_context.relationship == "subordinate" -%}
8+
This is a report from a subordinate agent. They may be escalating, reporting status, or requesting guidance.
9+
{%- else -%}
10+
This is a peer agent. Communication is collaborative and informational.
11+
{%- endif %}
12+
{%- endif %}
Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
{%- if org_context %}
2+
## Organization
3+
4+
You are part of a multi-agent system. Here is your position:
5+
6+
{% if org_context.superiors -%}
7+
**Reports to:**
8+
{% for agent in org_context.superiors -%}
9+
- **{{ agent.name }}** — your superior. Messages from this agent carry organizational authority.
10+
{% endfor %}
11+
{%- endif %}
12+
13+
{% if org_context.subordinates -%}
14+
**Direct reports:**
15+
{% for agent in org_context.subordinates -%}
16+
- **{{ agent.name }}** — reports to you. You can delegate tasks, request status, and send directives.
17+
{% endfor %}
18+
{%- endif %}
19+
20+
{% if org_context.peers -%}
21+
**Peers:**
22+
{% for agent in org_context.peers -%}
23+
- **{{ agent.name }}** — equal peer. Communication is collaborative and informational.
24+
{% endfor %}
25+
{%- endif %}
26+
27+
Use the `send_agent_message` tool to communicate with these agents. Each link has a dedicated internal channel with full conversation history.
28+
{%- endif %}
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
Send a message to another agent through the agent communication graph. The message is delivered to a dedicated internal channel between you and the target agent. Use this to coordinate, delegate, escalate, or share information with linked agents.

src/agent/channel.rs

Lines changed: 142 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -145,6 +145,8 @@ pub struct Channel {
145145
pending_retrigger_metadata: HashMap<String, serde_json::Value>,
146146
/// Deadline for firing the pending retrigger (debounce timer).
147147
retrigger_deadline: Option<tokio::time::Instant>,
148+
/// Optional send_agent_message tool (only when agent has active links).
149+
send_agent_message_tool: Option<crate::tools::SendAgentMessageTool>,
148150
}
149151

150152
impl Channel {
@@ -226,6 +228,7 @@ impl Channel {
226228
pending_retrigger: false,
227229
pending_retrigger_metadata: HashMap::new(),
228230
retrigger_deadline: None,
231+
send_agent_message_tool: None,
229232
};
230233

231234
(channel, message_tx)
@@ -623,10 +626,13 @@ impl Channel {
623626

624627
let available_channels = self.build_available_channels().await;
625628

629+
let org_context = self.build_org_context(&prompt_engine).await;
630+
let link_context = self.build_link_context(&prompt_engine).await;
631+
626632
let empty_to_none = |s: String| if s.is_empty() { None } else { Some(s) };
627633

628634
prompt_engine
629-
.render_channel_prompt(
635+
.render_channel_prompt_with_links(
630636
empty_to_none(identity_context),
631637
empty_to_none(memory_bulletin.to_string()),
632638
empty_to_none(skills_prompt),
@@ -635,6 +641,8 @@ impl Channel {
635641
empty_to_none(status_text),
636642
coalesce_hint,
637643
available_channels,
644+
org_context,
645+
link_context,
638646
)
639647
.expect("failed to render channel prompt")
640648
}
@@ -674,6 +682,20 @@ impl Channel {
674682
Vec::new()
675683
};
676684

685+
// Emit AgentMessageReceived event for internal agent-to-agent messages
686+
if message.source == "internal" {
687+
if let Some(from_agent_id) = message.metadata.get("from_agent_id").and_then(|v| v.as_str()) {
688+
if let Some(link_id) = message.metadata.get("link_id").and_then(|v| v.as_str()) {
689+
self.deps.event_tx.send(ProcessEvent::AgentMessageReceived {
690+
from_agent_id: Arc::from(from_agent_id),
691+
to_agent_id: self.deps.agent_id.clone(),
692+
link_id: link_id.to_string(),
693+
channel_id: self.id.clone(),
694+
}).ok();
695+
}
696+
}
697+
}
698+
677699
// Persist user messages (skip system re-triggers)
678700
if message.source != "system" {
679701
let sender_name = message
@@ -794,6 +816,118 @@ impl Channel {
794816
prompt_engine.render_available_channels(entries).ok()
795817
}
796818

819+
/// Build org context showing the agent's position in the communication hierarchy.
820+
async fn build_org_context(
821+
&self,
822+
prompt_engine: &crate::prompts::PromptEngine,
823+
) -> Option<String> {
824+
let link_store = self.deps.link_store.as_ref()?;
825+
let agent_id = self.deps.agent_id.as_ref();
826+
827+
let links = match link_store.get_links_for_agent(agent_id).await {
828+
Ok(links) => links,
829+
Err(error) => {
830+
tracing::warn!(%error, "failed to fetch links for org context");
831+
return None;
832+
}
833+
};
834+
835+
if links.is_empty() {
836+
return None;
837+
}
838+
839+
let mut superiors = Vec::new();
840+
let mut subordinates = Vec::new();
841+
let mut peers = Vec::new();
842+
843+
for link in &links {
844+
if !link.enabled {
845+
continue;
846+
}
847+
848+
let is_from = link.from_agent_id == agent_id;
849+
let other_agent_id = if is_from {
850+
&link.to_agent_id
851+
} else {
852+
&link.from_agent_id
853+
};
854+
855+
// Determine relationship from this agent's perspective
856+
let relationship = if is_from {
857+
link.relationship
858+
} else {
859+
link.relationship.inverse()
860+
};
861+
862+
// For one-way links where this agent is the to_agent (can't initiate),
863+
// still show them in the org context for awareness
864+
let agent_info = crate::prompts::engine::LinkedAgent {
865+
name: other_agent_id.clone(),
866+
id: other_agent_id.clone(),
867+
};
868+
869+
match relationship {
870+
crate::links::LinkRelationship::Superior => superiors.push(agent_info),
871+
crate::links::LinkRelationship::Subordinate => subordinates.push(agent_info),
872+
crate::links::LinkRelationship::Peer => peers.push(agent_info),
873+
}
874+
}
875+
876+
if superiors.is_empty() && subordinates.is_empty() && peers.is_empty() {
877+
return None;
878+
}
879+
880+
let org_context = crate::prompts::engine::OrgContext {
881+
superiors,
882+
subordinates,
883+
peers,
884+
};
885+
886+
prompt_engine.render_org_context(org_context).ok()
887+
}
888+
889+
/// Build link context for the current channel if it's an internal agent-to-agent channel.
890+
async fn build_link_context(
891+
&self,
892+
prompt_engine: &crate::prompts::PromptEngine,
893+
) -> Option<String> {
894+
// Link channels have conversation IDs starting with "link:"
895+
let conversation_id = self.conversation_id.as_deref()?;
896+
let link_id = conversation_id.strip_prefix("link:")?;
897+
898+
let link_store = self.deps.link_store.as_ref()?;
899+
let link = match link_store.get(link_id).await {
900+
Ok(Some(link)) => link,
901+
Ok(None) => return None,
902+
Err(error) => {
903+
tracing::warn!(%error, "failed to fetch link for channel context");
904+
return None;
905+
}
906+
};
907+
908+
let agent_id = self.deps.agent_id.as_ref();
909+
let is_from = link.from_agent_id == agent_id;
910+
let other_agent_id = if is_from {
911+
&link.to_agent_id
912+
} else {
913+
&link.from_agent_id
914+
};
915+
916+
// Relationship from the other agent's perspective toward this agent
917+
let relationship = if is_from {
918+
link.relationship
919+
} else {
920+
link.relationship.inverse()
921+
};
922+
923+
let link_context = crate::prompts::engine::LinkContext {
924+
agent_name: other_agent_id.clone(),
925+
relationship: relationship.as_str().to_string(),
926+
};
927+
928+
prompt_engine.render_link_context(link_context).ok()
929+
}
930+
797931
/// Assemble the full system prompt using the PromptEngine.
798932
async fn build_system_prompt(&self) -> String {
799933
let rc = &self.deps.runtime_config;
@@ -818,10 +952,13 @@ impl Channel {
818952

819953
let available_channels = self.build_available_channels().await;
820954

955+
let org_context = self.build_org_context(&prompt_engine).await;
956+
let link_context = self.build_link_context(&prompt_engine).await;
957+
821958
let empty_to_none = |s: String| if s.is_empty() { None } else { Some(s) };
822959

823960
prompt_engine
824-
.render_channel_prompt(
961+
.render_channel_prompt_with_links(
825962
empty_to_none(identity_context),
826963
empty_to_none(memory_bulletin.to_string()),
827964
empty_to_none(skills_prompt),
@@ -830,6 +967,8 @@ impl Channel {
830967
empty_to_none(status_text),
831968
None, // coalesce_hint - only set for batched messages
832969
available_channels,
970+
org_context,
971+
link_context,
833972
)
834973
.expect("failed to render channel prompt")
835974
}
@@ -860,6 +999,7 @@ impl Channel {
860999
skip_flag.clone(),
8611000
replied_flag.clone(),
8621001
self.deps.cron_tool.clone(),
1002+
self.send_agent_message_tool.clone(),
8631003
)
8641004
.await
8651005
{

src/api.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ mod config;
1111
mod cortex;
1212
mod cron;
1313
mod ingest;
14+
mod links;
1415
mod mcp;
1516
mod memories;
1617
mod messaging;

src/api/agents.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -432,6 +432,7 @@ pub(super) async fn create_agent(
432432
let guard = state.messaging_manager.read().await;
433433
guard.as_ref().cloned()
434434
},
435+
link_store: state.link_store.load().as_ref().clone(),
435436
};
436437

437438
let event_rx = event_tx.subscribe();

0 commit comments

Comments
 (0)