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
10 changes: 10 additions & 0 deletions prompts/en/adapters/signal.md.j2
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
## Signal Adapter Guidance

You are in a Signal conversation. Signal supports cross-channel messaging — you can send messages to other Signal users and groups using the `send_message_to_another_channel` tool.

**Supported Signal targets:**
- `signal:uuid:{uuid}` — Send to a Signal user by their UUID
- `signal:group:{group_id}` — Send to a Signal group
- `signal:+{phone}` or `signal:e164:+{phone}` — Send to a Signal user by phone number

Use this when the user asks you to message someone else on Signal or post to a Signal group.
5 changes: 5 additions & 0 deletions prompts/en/tools/send_message_description.md.j2
Original file line number Diff line number Diff line change
@@ -1 +1,6 @@
Send a message to a DIFFERENT channel than the one you are currently in. Use this for cross-channel delivery — reminders, notifications, or when the user asks you to post something in another channel or DM them. Do NOT use this to reply in the current conversation — use the `reply` tool for that. Target channels by name or ID from the available channels in your context.

For Signal messaging, you can use these explicit targets:
- `signal:uuid:{uuid}` - Send to a Signal user by their UUID
- `signal:group:{group_id}` - Send to a Signal group
- `signal:+{phone}` or `signal:e164:+{phone}` — Send to a Signal user by phone number
36 changes: 32 additions & 4 deletions src/agent/channel.rs
Original file line number Diff line number Diff line change
Expand Up @@ -823,7 +823,7 @@ impl Channel {
}
let supported_source = matches!(
message.source.as_str(),
"telegram" | "discord" | "slack" | "twitch"
"telegram" | "discord" | "slack" | "twitch" | "signal"
);
if !supported_source {
return Ok(false);
Expand Down Expand Up @@ -1202,11 +1202,13 @@ impl Channel {
self.conversation_id = Some(first.conversation_id.clone());
}

// Track source adapter from the first non-system message
// Prefer message.adapter (full adapter string like "signal:work") over message.source
if self.source_adapter.is_none()
&& let Some(first) = messages.first()
&& first.source != "system"
{
self.source_adapter = Some(first.source.clone());
self.source_adapter = first.adapter.clone().or_else(|| Some(first.source.clone()));
}

// Capture conversation context from the first message
Expand Down Expand Up @@ -1408,6 +1410,13 @@ impl Channel {
.build_system_prompt_with_coalesce(message_count, elapsed_secs, unique_sender_count)
.await?;

// Extract adapter from messages (prefer explicit message.adapter, fall back to stored source_adapter)
// This preserves per-message adapter for Signal named instances (e.g., "signal:work")
let batch_adapter = messages
.iter()
.find_map(|m| m.adapter.as_deref())
.or(self.source_adapter.as_deref());

{
let mut reply_target = self.state.reply_target_message_id.write().await;
*reply_target = messages.iter().rev().find_map(extract_message_id);
Expand All @@ -1421,6 +1430,7 @@ impl Channel {
&conversation_id,
attachment_parts,
false, // not a retrigger
batch_adapter,
)
.await?;

Expand Down Expand Up @@ -1552,8 +1562,13 @@ impl Channel {
self.conversation_id = Some(message.conversation_id.clone());
}

// Track source adapter from non-system messages
// Prefer message.adapter (full adapter string like "signal:work") over message.source
if self.source_adapter.is_none() && message.source != "system" {
self.source_adapter = Some(message.source.clone());
self.source_adapter = message
.adapter
.clone()
.or_else(|| Some(message.source.clone()));
}

let (raw_text, attachments) = match &message.content {
Expand Down Expand Up @@ -1743,13 +1758,18 @@ impl Channel {
Vec::new()
};

let adapter = message
.adapter
.as_deref()
.or_else(|| self.current_adapter());
let (result, skip_flag, replied_flag, retrigger_reply_preserved) = self
.run_agent_turn(
&user_text,
&system_prompt,
&message.conversation_id,
attachment_content,
is_retrigger,
adapter,
)
.await?;

Expand All @@ -1765,7 +1785,7 @@ impl Channel {
&& !replied_flag.load(std::sync::atomic::Ordering::Relaxed)
&& matches!(
message.source.as_str(),
"discord" | "telegram" | "slack" | "twitch"
"discord" | "telegram" | "slack" | "twitch" | "signal"
)
{
self.send_builtin_text(
Expand Down Expand Up @@ -2171,6 +2191,7 @@ impl Channel {
conversation_id: &str,
attachment_content: Vec<UserContent>,
is_retrigger: bool,
adapter: Option<&str>,
) -> Result<(
std::result::Result<String, rig::completion::PromptError>,
crate::tools::SkipFlag,
Expand Down Expand Up @@ -2198,6 +2219,7 @@ impl Channel {
self.deps.cron_tool.clone(),
send_agent_message_tool,
allow_direct_reply,
adapter.map(|s| s.to_string()),
)
.await
{
Expand Down Expand Up @@ -2582,6 +2604,12 @@ impl Channel {
.channel_errors_total
.with_label_values(&[metrics_agent_id, metrics_channel_type, "llm_error"])
.inc();
// Send error to user so they know something went wrong
let error_msg = format!("I encountered an error: {}", error);
self.response_tx
.send(OutboundResponse::Text(error_msg))
.await
.ok();
tracing::error!(channel_id = %self.id, %error, "channel LLM call failed");
}
}
Expand Down
12 changes: 11 additions & 1 deletion src/agent/channel_history.rs
Original file line number Diff line number Diff line change
Expand Up @@ -317,7 +317,17 @@ pub(crate) fn format_user_message(
raw_text
};

format!("{display_name}{bot_tag}{reply_context} [{timestamp_text}]: {text_content}")
let sender_context = message
.metadata
.get("sender_context")
.and_then(|v| v.as_str())
.filter(|s| !s.is_empty())
.map(|s| format!(" {s}"))
.unwrap_or_default();

format!(
"{display_name}{bot_tag}{reply_context}{sender_context} [{timestamp_text}]: {text_content}"
)
}

pub(crate) fn format_batched_user_message(
Expand Down
8 changes: 7 additions & 1 deletion src/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ pub(crate) use load::resolve_env_value;
pub use load::set_resolve_secrets_store;
pub use onboarding::run_onboarding;
pub use permissions::{
DiscordPermissions, SlackPermissions, TelegramPermissions, TwitchPermissions,
DiscordPermissions, SignalPermissions, SlackPermissions, TelegramPermissions, TwitchPermissions,
};
pub(crate) use providers::default_provider_config;
pub use runtime::RuntimeConfig;
Expand Down Expand Up @@ -1539,6 +1539,7 @@ maintenance_merge_similarity_threshold = 1.1
email: None,
webhook: None,
twitch: None,
signal: None,
};
let bindings = vec![
Binding {
Expand All @@ -1548,6 +1549,7 @@ maintenance_merge_similarity_threshold = 1.1
guild_id: None,
workspace_id: None,
chat_id: None,

channel_ids: vec![],
require_mention: false,
dm_allowed_users: vec![],
Expand All @@ -1559,6 +1561,7 @@ maintenance_merge_similarity_threshold = 1.1
guild_id: None,
workspace_id: None,
chat_id: None,

channel_ids: vec![],
require_mention: false,
dm_allowed_users: vec![],
Expand All @@ -1581,6 +1584,7 @@ maintenance_merge_similarity_threshold = 1.1
email: None,
webhook: None,
twitch: None,
signal: None,
};
let bindings = vec![Binding {
agent_id: "main".into(),
Expand Down Expand Up @@ -1643,6 +1647,7 @@ maintenance_merge_similarity_threshold = 1.1
}),
webhook: None,
twitch: None,
signal: None,
};
let bindings = vec![Binding {
agent_id: "main".into(),
Expand Down Expand Up @@ -1677,6 +1682,7 @@ maintenance_merge_similarity_threshold = 1.1
email: None,
webhook: None,
twitch: None,
signal: None,
};
// Binding targets default adapter, but no default credentials exist
let bindings = vec![Binding {
Expand Down
59 changes: 55 additions & 4 deletions src/config/load.rs
Original file line number Diff line number Diff line change
Expand Up @@ -14,10 +14,10 @@ use super::{
CoalesceConfig, CompactionConfig, Config, CortexConfig, CronDef, DefaultsConfig, DiscordConfig,
DiscordInstanceConfig, EmailConfig, EmailInstanceConfig, GroupDef, HumanDef, IngestionConfig,
LinkDef, LlmConfig, McpServerConfig, McpTransport, MemoryPersistenceConfig, MessagingConfig,
MetricsConfig, OpenCodeConfig, ProjectsConfig, ProviderConfig, SlackCommandConfig, SlackConfig,
SlackInstanceConfig, TelegramConfig, TelegramInstanceConfig, TelemetryConfig, TwitchConfig,
TwitchInstanceConfig, WarmupConfig, WebhookConfig, normalize_adapter,
validate_named_messaging_adapters,
MetricsConfig, OpenCodeConfig, ProjectsConfig, ProviderConfig, SignalConfig,
SignalInstanceConfig, SlackCommandConfig, SlackConfig, SlackInstanceConfig, TelegramConfig,
TelegramInstanceConfig, TelemetryConfig, TwitchConfig, TwitchInstanceConfig, WarmupConfig,
WebhookConfig, normalize_adapter, validate_named_messaging_adapters,
};
use crate::error::{ConfigError, Result};

Expand Down Expand Up @@ -2148,6 +2148,57 @@ impl Config {
trigger_prefix: t.trigger_prefix,
})
}),
signal: toml.messaging.signal.and_then(|s| {
let instances = s
.instances
.into_iter()
.map(|instance| {
let http_url = instance.http_url.as_deref().and_then(resolve_env_value);
let account = instance.account.as_deref().and_then(resolve_env_value);
if instance.enabled && (http_url.is_none() || account.is_none()) {
tracing::warn!(
adapter = %instance.name,
"signal instance is enabled but http_url or account is missing/unresolvable — disabling"
);
}
let has_credentials = http_url.is_some() && account.is_some();
SignalInstanceConfig {
name: instance.name,
enabled: instance.enabled && has_credentials,
http_url: http_url.unwrap_or_default(),
account: account.unwrap_or_default(),
dm_allowed_users: instance.dm_allowed_users,
group_ids: instance.group_ids,
group_allowed_users: instance.group_allowed_users,
ignore_stories: instance.ignore_stories,
}
})
.collect::<Vec<_>>();

let http_url = std::env::var("SIGNAL_HTTP_URL")
.ok()
.or_else(|| s.http_url.as_deref().and_then(resolve_env_value));
let account = std::env::var("SIGNAL_ACCOUNT")
.ok()
.or_else(|| s.account.as_deref().and_then(resolve_env_value));

if (http_url.is_none() || account.is_none())
&& !instances.iter().any(|inst| inst.enabled)
{
return None;
}

Some(SignalConfig {
enabled: s.enabled,
http_url: http_url.unwrap_or_default(),
account: account.unwrap_or_default(),
instances,
dm_allowed_users: s.dm_allowed_users,
group_ids: s.group_ids,
group_allowed_users: s.group_allowed_users,
ignore_stories: s.ignore_stories,
})
}),
};

let bindings: Vec<Binding> = toml
Expand Down
Loading
Loading