Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions prompts/en/fragments/org_context.md.j2
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ You are part of a multi-agent system. Here is your position:
{% if entry.is_human -%}
- **{{ entry.name }}** (human) — your human superior. Treat their requests with highest priority.
{% else -%}
- **{{ entry.name }}** — your superior. Messages from this agent carry organizational authority.
- **{{ entry.name }}** — your superior. Tasks from this agent carry organizational authority.
{% endif -%}
{% endfor %}
{%- endif %}
Expand All @@ -36,5 +36,5 @@ You are part of a multi-agent system. Here is your position:
{% endfor %}
{%- endif %}

Linked agents can be assigned tasks and queried for status through the task system. Use organizational awareness to delegate appropriately — assign work downward to subordinates, escalate upward to superiors, and coordinate laterally with peers.
Use `send_agent_message` to assign tasks to linked agents. Delegated tasks are executed autonomously by the target agent's cortex — you will be notified when they complete. Assign work downward to subordinates, escalate upward to superiors, and coordinate laterally with peers.
{%- endif %}
2 changes: 1 addition & 1 deletion prompts/en/tools/send_agent_message_description.md.j2
Original file line number Diff line number Diff line change
@@ -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.
Assign a task to another agent. The target agent's cortex will pick it up and execute it autonomously. Use this when work falls outside your scope or belongs to a subordinate. Your turn ends after delegation — the result will be delivered when the task completes. The first sentence of your message becomes the task title; the full content is the task description.
11 changes: 10 additions & 1 deletion src/agent/channel.rs
Original file line number Diff line number Diff line change
Expand Up @@ -239,6 +239,8 @@ impl Channel {
deps.agent_id.clone(),
deps.links.clone(),
deps.agent_names.clone(),
deps.task_store_registry.clone(),
ConversationLogger::new(deps.sqlite_pool.clone()),
))
} else {
None
Expand Down Expand Up @@ -1068,6 +1070,13 @@ impl Channel {
let replied_flag = crate::tools::new_replied_flag();
let allow_direct_reply = !self.suppress_plaintext_fallback();

// Set the originating channel on the delegation tool so task completion
// notifications route back to this conversation.
let send_agent_message_tool = self
.send_agent_message_tool
.clone()
.map(|tool| tool.with_originating_channel(conversation_id.to_string()));

if let Err(error) = crate::tools::add_channel_tools(
&self.tool_server,
self.state.clone(),
Expand All @@ -1076,7 +1085,7 @@ impl Channel {
skip_flag.clone(),
replied_flag.clone(),
self.deps.cron_tool.clone(),
self.send_agent_message_tool.clone(),
send_agent_message_tool,
allow_direct_reply,
)
.await
Expand Down
147 changes: 147 additions & 0 deletions src/agent/cortex.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1269,6 +1269,10 @@ async fn pickup_one_ready_task(deps: &AgentDeps, logger: &CortexLogger) -> anyho
let agent_id = deps.agent_id.to_string();
let event_tx = deps.event_tx.clone();
let logger = logger.clone();
let injection_tx = deps.injection_tx.clone();
let links = deps.links.clone();
let agent_names = deps.agent_names.clone();
let sqlite_pool = deps.sqlite_pool.clone();
tokio::spawn(async move {
match worker.run().await {
Ok(result_text) => {
Expand Down Expand Up @@ -1308,6 +1312,20 @@ async fn pickup_one_ready_task(deps: &AgentDeps, logger: &CortexLogger) -> anyho
})),
);

// Handle delegated task completion: log to link channel and
// notify the delegating agent's originating channel.
notify_delegation_completion(
&task,
&result_text,
true,
&agent_id,
&links,
&agent_names,
&sqlite_pool,
&injection_tx,
)
.await;

let _ = event_tx.send(ProcessEvent::WorkerComplete {
agent_id: Arc::from(agent_id.as_str()),
worker_id,
Expand Down Expand Up @@ -1357,6 +1375,20 @@ async fn pickup_one_ready_task(deps: &AgentDeps, logger: &CortexLogger) -> anyho
})),
);

// Handle delegated task failure: log to link channel and
// notify the delegating agent's originating channel.
notify_delegation_completion(
&task,
&error_message,
false,
&agent_id,
&links,
&agent_names,
&sqlite_pool,
&injection_tx,
)
.await;

let _ = event_tx.send(ProcessEvent::WorkerComplete {
agent_id: Arc::from(agent_id.as_str()),
worker_id,
Expand All @@ -1372,6 +1404,121 @@ async fn pickup_one_ready_task(deps: &AgentDeps, logger: &CortexLogger) -> anyho
Ok(())
}

/// When a task with `metadata.delegating_agent_id` completes or fails, log the
/// result in the link channel between the two agents and inject a retrigger
/// system message into the delegating agent's originating channel so the user
/// gets notified.
#[allow(clippy::too_many_arguments)]
async fn notify_delegation_completion(
task: &crate::tasks::Task,
result_summary: &str,
success: bool,
executor_agent_id: &str,
links: &arc_swap::ArcSwap<Vec<crate::links::AgentLink>>,
agent_names: &std::collections::HashMap<String, String>,
sqlite_pool: &sqlx::SqlitePool,
injection_tx: &tokio::sync::mpsc::Sender<crate::ChannelInjection>,
) {
// Check if this is a delegated task.
let delegating_agent_id = task
.metadata
.get("delegating_agent_id")
.and_then(|v| v.as_str());

let Some(delegating_agent_id) = delegating_agent_id else {
return; // Not a delegated task.
};

let originating_channel = task
.metadata
.get("originating_channel")
.and_then(|v| v.as_str());

let executor_display = agent_names
.get(executor_agent_id)
.cloned()
.unwrap_or_else(|| executor_agent_id.to_string());

let status_word = if success { "completed" } else { "failed" };
let link_message = format!(
"{executor_display} {status_word} task #{}: \"{}\"",
task.task_number, task.title
);

// Log completion in the link channel on both sides.
let all_links = links.load();
if let Some(link) =
crate::links::find_link_between(&all_links, executor_agent_id, delegating_agent_id)
{
let conversation_logger =
crate::conversation::history::ConversationLogger::new(sqlite_pool.clone());
let executor_link_channel = link.channel_id_for(executor_agent_id);
let delegator_link_channel = link.channel_id_for(delegating_agent_id);
conversation_logger.log_system_message(&executor_link_channel, &link_message);
conversation_logger.log_system_message(&delegator_link_channel, &link_message);
}

// Inject a retrigger into the originating channel so the delegating agent
// can relay the result to the user.
let Some(originating_channel) = originating_channel else {
tracing::info!(
task_number = task.task_number,
delegating_agent_id,
"delegated task completed but no originating_channel in metadata, skipping retrigger"
);
return;
};

// Truncate very long results for the notification message.
let truncated_result = if result_summary.len() > 500 {
let boundary = result_summary.floor_char_boundary(500);
format!("{}... [truncated]", &result_summary[..boundary])
} else {
result_summary.to_string()
};

let notification_text = format!(
Comment thread
jamiepine marked this conversation as resolved.
"[System] Delegated task #{} {status_word} by {executor_display}: \"{}\"\n\nResult: {truncated_result}",
task.task_number, task.title,
);

let injection = crate::ChannelInjection {
conversation_id: originating_channel.to_string(),
agent_id: delegating_agent_id.to_string(),
message: crate::InboundMessage {
id: uuid::Uuid::new_v4().to_string(),
source: "system".into(),
adapter: None,
conversation_id: originating_channel.to_string(),
sender_id: "system".into(),
agent_id: Some(delegating_agent_id.to_string().into()),
content: crate::MessageContent::Text(notification_text),
timestamp: chrono::Utc::now(),
metadata: std::collections::HashMap::new(),
formatted_author: None,
},
};

if let Err(error) = injection_tx.send(injection).await {
tracing::warn!(
%error,
task_number = task.task_number,
originating_channel,
delegating_agent_id,
"failed to inject delegation completion retrigger"
);
} else {
tracing::info!(
task_number = task.task_number,
originating_channel,
delegating_agent_id,
executor_agent_id,
success,
"injected delegation completion retrigger"
);
}
}

async fn run_association_loop(deps: &AgentDeps, logger: &CortexLogger) -> anyhow::Result<()> {
tracing::info!("cortex association loop started");

Expand Down
20 changes: 19 additions & 1 deletion src/api/agents.rs
Original file line number Diff line number Diff line change
Expand Up @@ -400,6 +400,8 @@ pub(super) async fn trigger_warmup(
let llm_manager = llm_manager.clone();
let force = request.force;
let agent_id = agent_id.clone();
let task_store_registry = state.task_store_registry.clone();
let injection_tx = state.injection_tx.clone();
tokio::spawn(async move {
let (event_tx, _event_rx) = tokio::sync::broadcast::channel(16);
let deps = crate::AgentDeps {
Expand All @@ -416,6 +418,8 @@ pub(super) async fn trigger_warmup(
task_store,
links: Arc::new(arc_swap::ArcSwap::from_pointee(Vec::new())),
agent_names: Arc::new(std::collections::HashMap::new()),
task_store_registry,
injection_tx,
};
let logger = CortexLogger::new(sqlite_pool);
crate::agent::cortex::run_warmup_once(&deps, &logger, "api_trigger", force).await;
Expand Down Expand Up @@ -716,6 +720,8 @@ pub(super) async fn create_agent(
links: Arc::new(arc_swap::ArcSwap::from_pointee(
(**state.agent_links.load()).clone(),
)),
task_store_registry: state.task_store_registry.clone(),
injection_tx: state.injection_tx.clone(),
Comment thread
coderabbitai[bot] marked this conversation as resolved.
agent_names: {
let configs = state.agent_configs.load();
let mut names: std::collections::HashMap<String, String> = configs
Expand Down Expand Up @@ -825,9 +831,15 @@ pub(super) async fn create_agent(
state.memory_searches.store(std::sync::Arc::new(searches));

let mut task_stores = (**state.task_stores.load()).clone();
task_stores.insert(agent_id.clone(), task_store);
task_stores.insert(agent_id.clone(), task_store.clone());
state.task_stores.store(std::sync::Arc::new(task_stores));

let mut registry = (**state.task_store_registry.load()).clone();
registry.insert(agent_id.clone(), task_store);
state
.task_store_registry
.store(std::sync::Arc::new(registry));
Comment on lines 833 to +841

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Keep registry lifecycle symmetric: remove deleted agents from task_store_registry.

This create path now inserts into state.task_store_registry, but deletion should also remove the same key. Otherwise stale task-store entries can remain routable after agent teardown.

🔧 Companion cleanup to add in delete flow
+        let mut task_stores = (**state.task_stores.load()).clone();
+        task_stores.remove(&agent_id);
+        state.task_stores.store(std::sync::Arc::new(task_stores));
+
+        let mut registry = (**state.task_store_registry.load()).clone();
+        registry.remove(&agent_id);
+        state
+            .task_store_registry
+            .store(std::sync::Arc::new(registry));
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/api/agents.rs` around lines 833 - 841, The create path adds the new agent
into both state.task_stores and state.task_store_registry but the
delete/teardown flow doesn't remove the agent from task_store_registry, leaving
stale entries; update the agent deletion logic to mirror the create path by
loading, cloning, removing the agent_id key from both state.task_stores and
state.task_store_registry (use the same pattern as shown: let mut registry =
(**state.task_store_registry.load()).clone(); registry.remove(&agent_id);
state.task_store_registry.store(std::sync::Arc::new(registry));), ensuring the
same agent_id used when inserting is removed during teardown.


let mut workspaces = (**state.agent_workspaces.load()).clone();
workspaces.insert(agent_id.clone(), agent_config.workspace.clone());
state
Expand Down Expand Up @@ -1478,10 +1490,16 @@ mod tests {
let (agent_tx, _agent_rx) = tokio::sync::mpsc::channel(1);
let (agent_remove_tx, _agent_remove_rx) = tokio::sync::mpsc::channel(1);

let (injection_tx, _injection_rx) = tokio::sync::mpsc::channel(1);
let task_store_registry = Arc::new(arc_swap::ArcSwap::from_pointee(
std::collections::HashMap::new(),
));
Arc::new(ApiState::new_with_provider_sender(
provider_setup_tx,
agent_tx,
agent_remove_tx,
injection_tx,
task_store_registry,
))
}

Expand Down
11 changes: 11 additions & 0 deletions src/api/state.rs
Original file line number Diff line number Diff line change
Expand Up @@ -104,6 +104,11 @@ pub struct ApiState {
pub agent_remove_tx: mpsc::Sender<String>,
/// Shared webchat adapter for session management from API handlers.
pub webchat_adapter: ArcSwap<Option<Arc<WebChatAdapter>>>,
/// Cross-agent task store registry for delegation.
pub task_store_registry:
Arc<ArcSwap<std::collections::HashMap<String, Arc<crate::tasks::TaskStore>>>>,
/// Sender for cross-agent message injection.
pub injection_tx: mpsc::Sender<crate::ChannelInjection>,
/// Instance-level agent links for the communication graph.
pub agent_links: ArcSwap<Vec<crate::links::AgentLink>>,
/// Visual agent groups for the topology UI.
Expand Down Expand Up @@ -222,6 +227,10 @@ impl ApiState {
provider_setup_tx: mpsc::Sender<crate::ProviderSetupEvent>,
agent_tx: mpsc::Sender<crate::Agent>,
agent_remove_tx: mpsc::Sender<String>,
injection_tx: mpsc::Sender<crate::ChannelInjection>,
task_store_registry: Arc<
ArcSwap<std::collections::HashMap<String, Arc<crate::tasks::TaskStore>>>,
>,
) -> Self {
let (event_tx, _) = broadcast::channel(512);
Self {
Expand Down Expand Up @@ -256,6 +265,8 @@ impl ApiState {
defaults_config: RwLock::new(None),
agent_tx,
agent_remove_tx,
task_store_registry,
injection_tx,
webchat_adapter: ArcSwap::from_pointee(None),
agent_links: ArcSwap::from_pointee(Vec::new()),
agent_groups: ArcSwap::from_pointee(Vec::new()),
Expand Down
27 changes: 27 additions & 0 deletions src/conversation/history.rs
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,33 @@ impl ConversationLogger {
self.log_bot_message_with_name(channel_id, content, None);
}

/// Log a system message (e.g. task delegation audit record). Fire-and-forget.
Comment thread
jamiepine marked this conversation as resolved.
///
/// System messages are persisted with role `"system"` and are not fed to any
/// LLM context window. They exist purely for UI display in link channel
/// timelines and audit logs.
pub fn log_system_message(&self, channel_id: &str, content: &str) {
let pool = self.pool.clone();
let id = uuid::Uuid::new_v4().to_string();
let channel_id = channel_id.to_string();
let content = content.to_string();

tokio::spawn(async move {
if let Err(error) = sqlx::query(
"INSERT INTO conversation_messages (id, channel_id, role, sender_name, content) \
VALUES (?, ?, 'system', 'system', ?)",
)
.bind(&id)
.bind(&channel_id)
.bind(&content)
.execute(&pool)
.await
{
tracing::warn!(%error, %channel_id, "failed to persist system message");
}
});
}

/// Log a bot (assistant) message with an agent display name. Fire-and-forget.
pub fn log_bot_message_with_name(
&self,
Expand Down
21 changes: 21 additions & 0 deletions src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -198,6 +198,18 @@ pub enum ProcessEvent {
},
}

/// A message to be injected into a specific channel from outside the normal
/// inbound message flow. Used for cross-agent task completion notifications.
#[derive(Debug, Clone)]
pub struct ChannelInjection {
/// The conversation_id of the target channel.
pub conversation_id: String,
/// The agent that owns the target channel.
pub agent_id: String,
/// The message to inject.
pub message: InboundMessage,
}

/// Shared dependency bundle for agent processes.
#[derive(Clone)]
pub struct AgentDeps {
Expand All @@ -215,6 +227,15 @@ pub struct AgentDeps {
pub links: Arc<arc_swap::ArcSwap<Vec<links::AgentLink>>>,
/// Map of all agent IDs to display names, for inter-agent message routing.
pub agent_names: Arc<std::collections::HashMap<String, String>>,
/// Cross-agent task store registry. Maps agent_id → TaskStore for agents
/// reachable via links. Used by `send_agent_message` to create tasks on
/// target agents and by the cortex to look up delegation metadata.
/// Populated after all agents are initialized.
pub task_store_registry:
Arc<arc_swap::ArcSwap<std::collections::HashMap<String, Arc<tasks::TaskStore>>>>,
/// Sender for injecting messages into channels from outside the normal
/// inbound message flow (e.g. cross-agent task completion notifications).
pub injection_tx: tokio::sync::mpsc::Sender<ChannelInjection>,
}

impl AgentDeps {
Expand Down
Loading