Skip to content

Commit a0d68ef

Browse files
committed
runtime restart discord command (admin-only)
1 parent 92f1159 commit a0d68ef

8 files changed

Lines changed: 115 additions & 49 deletions

File tree

crates/pattern_cli/src/discord.rs

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,8 @@ use pattern_discord::{
3030
endpoints::DiscordEndpoint,
3131
};
3232
#[cfg(feature = "discord")]
33+
use std::os::unix::process::CommandExt;
34+
#[cfg(feature = "discord")]
3335
use std::sync::Arc;
3436

3537
/// Set up Discord endpoint for an agent if configured
@@ -283,12 +285,15 @@ pub async fn run_discord_bot_with_group(
283285
let sinks =
284286
crate::forwarding::build_discord_group_sinks(&output, &agents_with_membership).await;
285287

288+
let (restart_tx, mut restart_rx) = tokio::sync::mpsc::channel(1);
289+
286290
let bot = Arc::new(DiscordBot::new_cli_mode(
287291
bot_cfg,
288292
agents_with_membership.clone(),
289293
group.clone(),
290294
pattern_manager.clone(),
291295
Some(sinks),
296+
restart_tx.clone(),
292297
));
293298

294299
// Connect the bot to the Discord endpoint for timing context
@@ -346,6 +351,19 @@ pub async fn run_discord_bot_with_group(
346351
}
347352
});
348353

354+
tokio::spawn(async move {
355+
restart_rx.recv().await;
356+
tracing::info!("restart signal received");
357+
let _ = crossterm::terminal::disable_raw_mode();
358+
359+
let exe = std::env::current_exe().unwrap();
360+
let args: Vec<String> = std::env::args().collect();
361+
362+
let _ = std::process::Command::new(exe).args(&args[1..]).exec();
363+
364+
std::process::exit(0);
365+
});
366+
349367
// Run CLI chat loop in foreground
350368
if let Some(rl) = rl {
351369
run_group_chat_loop(

crates/pattern_cli/src/forwarding.rs

Lines changed: 0 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,6 @@ use std::sync::Arc;
33

44
use async_trait::async_trait;
55
use chrono::Utc;
6-
use owo_colors::OwoColorize;
76
use pattern_core::{
87
Agent,
98
agent::ResponseEvent,
@@ -167,19 +166,6 @@ pub async fn build_discord_group_sinks(
167166
build_group_sinks(output, agents, "Discord").await
168167
}
169168

170-
/// Helper: build file-only sinks for CLI routes (avoid double printing)
171-
pub async fn build_cli_group_file_sinks() -> Vec<Arc<dyn GroupEventSink>> {
172-
let mut sinks: Vec<Arc<dyn GroupEventSink>> = Vec::new();
173-
if let Ok(path) = std::env::var("PATTERN_FORWARD_FILE") {
174-
if !path.trim().is_empty() {
175-
if let Ok(sink) = FileGroupSink::create(PathBuf::from(path)).await {
176-
sinks.push(Arc::new(sink));
177-
}
178-
}
179-
}
180-
sinks
181-
}
182-
183169
/// Build group sinks for CLI-initiated group chat (CLI printer + optional file)
184170
pub async fn build_cli_group_sinks(
185171
output: &Output,
@@ -196,19 +182,6 @@ pub async fn build_jetstream_group_sinks(
196182
build_group_sinks(output, agents, "Jetstream").await
197183
}
198184

199-
/// Build agent sinks for CLI agent chat (file-only to avoid double printing)
200-
pub async fn build_cli_agent_file_sinks() -> Vec<Arc<dyn AgentEventSink>> {
201-
let mut sinks: Vec<Arc<dyn AgentEventSink>> = Vec::new();
202-
if let Ok(path) = std::env::var("PATTERN_FORWARD_FILE") {
203-
if !path.trim().is_empty() {
204-
if let Ok(sink) = FileAgentSink::create(PathBuf::from(path)).await {
205-
sinks.push(Arc::new(sink));
206-
}
207-
}
208-
}
209-
sinks
210-
}
211-
212185
/// Build agent sinks for single-agent CLI chat: CLI printer + optional file
213186
pub async fn build_cli_agent_sinks(output: &Output) -> Vec<Arc<dyn AgentEventSink>> {
214187
let mut sinks: Vec<Arc<dyn AgentEventSink>> = Vec::new();

crates/pattern_core/src/agent/impls/db_agent.rs

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -14,8 +14,7 @@ use crate::db::DbEntity;
1414
use crate::id::RelationId;
1515
use crate::memory::MemoryType;
1616
use crate::message::{
17-
BatchType, ChatRole, ContentBlock, ContentPart, ImageSource, Request, Response, ToolCall,
18-
ToolResponse,
17+
BatchType, ContentBlock, ContentPart, ImageSource, Request, Response, ToolCall, ToolResponse,
1918
};
2019
use crate::model::ResponseOptions;
2120
use crate::tool::builtin::BuiltinTools;
@@ -28,7 +27,7 @@ use crate::{
2827
tool_rules::{ToolRule, ToolRuleEngine},
2928
},
3029
context::{
31-
AgentContext, CompressionStrategy, ContextConfig, NON_USER_MESSAGE_PREFIX,
30+
AgentContext, CompressionStrategy, ContextConfig,
3231
heartbeat::{HeartbeatSender, check_heartbeat_request},
3332
},
3433
db::{DatabaseError, ops, schema},

crates/pattern_core/src/coordination/patterns/sleeptime.rs

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ use std::{sync::Arc, time::Duration};
77
use crate::{
88
Result,
99
agent::Agent,
10+
context::NON_USER_MESSAGE_PREFIX,
1011
coordination::{
1112
groups::{
1213
AgentResponse, AgentWithMembership, GroupManager, GroupResponse, GroupResponseEvent,
@@ -168,7 +169,8 @@ impl GroupManager for SleeptimeManager {
168169
let trigger_names: Vec<_> =
169170
fired_triggers.iter().map(|t| t.name.as_str()).collect();
170171
let mut context = format!(
171-
"[Sleeptime Intervention] Triggers fired: {}. {}",
172+
"{}[Background Intervention] Triggers fired: {}. {}",
173+
NON_USER_MESSAGE_PREFIX,
172174
trigger_names.join(", "),
173175
Self::get_intervention_message_static(&fired_triggers)
174176
);
@@ -453,7 +455,10 @@ impl SleeptimeManager {
453455
},
454456
};
455457

456-
format!("[Periodic Context Sync] {}{}", now, prompt)
458+
format!(
459+
"{}[Periodic Context Sync] {}{}",
460+
NON_USER_MESSAGE_PREFIX, now, prompt
461+
)
457462
}
458463

459464
/// Find the agent that was least recently active

crates/pattern_core/src/model.rs

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -353,6 +353,9 @@ impl ModelProvider for GenAiClient {
353353
async fn complete(&self, options: &ResponseOptions, mut request: Request) -> Result<Response> {
354354
let (model_info, chat_options) = options.to_chat_options_tuple();
355355

356+
// Validate image URLs are accessible (to avoid anthropic's terrible error handling)
357+
self.validate_image_urls(&mut request).await;
358+
356359
// Convert URL images to base64 for Gemini models
357360
tracing::debug!(
358361
"Model ID: {}, checking if it starts with 'gemini'",
@@ -371,9 +374,6 @@ impl ModelProvider for GenAiClient {
371374
);
372375
}
373376

374-
// Validate image URLs are accessible (to avoid anthropic's terrible error handling)
375-
self.validate_image_urls(&mut request).await;
376-
377377
// Log the full request
378378
let chat_request = request.as_chat_request()?;
379379

crates/pattern_discord/src/bot.rs

Lines changed: 22 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -88,6 +88,9 @@ pub struct DiscordBot {
8888

8989
/// Cached bot user ID (set on Ready)
9090
bot_user_id: Arc<Mutex<Option<u64>>>,
91+
92+
/// restart channel sender
93+
restart_ch: tokio::sync::mpsc::Sender<()>,
9194
}
9295

9396
/// Configuration for the Discord bot
@@ -223,6 +226,7 @@ impl DiscordBot {
223226
group: AgentGroup,
224227
group_manager: Arc<dyn GroupManager>,
225228
group_event_sinks: Option<Vec<Arc<dyn GroupEventSink>>>,
229+
restart_ch: tokio::sync::mpsc::Sender<()>,
226230
) -> Self {
227231
Self {
228232
cli_mode: true,
@@ -242,11 +246,15 @@ impl DiscordBot {
242246
recent_activity_by_channel: Arc::new(Mutex::new(HashMap::new())),
243247
group_event_sinks,
244248
bot_user_id: Arc::new(Mutex::new(None)),
249+
restart_ch,
245250
}
246251
}
247252

248253
/// Create a new Discord bot for full mode (with database)
249-
pub fn new_full_mode(config: DiscordBotConfig) -> Self {
254+
pub fn new_full_mode(
255+
config: DiscordBotConfig,
256+
restart_ch: tokio::sync::mpsc::Sender<()>,
257+
) -> Self {
250258
Self {
251259
cli_mode: false,
252260
agents_with_membership: None,
@@ -265,6 +273,7 @@ impl DiscordBot {
265273
recent_activity_by_channel: Arc::new(Mutex::new(HashMap::new())),
266274
group_event_sinks: None,
267275
bot_user_id: Arc::new(Mutex::new(None)),
276+
restart_ch,
268277
}
269278
}
270279
}
@@ -375,8 +384,10 @@ impl EventHandler for DiscordEventHandler {
375384
if !sent {
376385
// Post to all configured channels
377386
for cid in &channel_ids {
378-
let _ = ChannelId::new(*cid).say(&http, content.clone()).await.ok();
379-
sent = true;
387+
if !sent {
388+
let _ = ChannelId::new(*cid).say(&http, content.clone()).await.ok();
389+
sent = true;
390+
}
380391
}
381392
}
382393
}
@@ -759,14 +770,15 @@ impl EventHandler for DiscordEventHandler {
759770
// Get agents and group for slash command handlers
760771
let agents = self.bot.agents_with_membership.as_deref();
761772
let group = self.bot.group.as_ref();
773+
let restart_ch = &self.bot.restart_ch;
762774

763775
let result = match command.data.name.as_str() {
764776
"help" => crate::slash_commands::handle_help_command(&ctx, &command, agents).await,
765777
"status" => {
766778
crate::slash_commands::handle_status_command(&ctx, &command, agents, group)
767779
.await
768780
}
769-
"memory" | "archival" | "context" | "search" => {
781+
"memory" | "archival" | "context" | "search" | "restart" => {
770782
// Check user authorization for sensitive commands
771783
if let Some(ref admin_users) = self.bot.config.admin_users {
772784
let user_id_str = command.user.id.get().to_string();
@@ -806,6 +818,12 @@ impl EventHandler for DiscordEventHandler {
806818
crate::slash_commands::handle_search_command(&ctx, &command, agents)
807819
.await
808820
}
821+
"restart" => {
822+
crate::slash_commands::handle_restart_command(
823+
&ctx, &command, restart_ch,
824+
)
825+
.await
826+
}
809827
_ => unreachable!(),
810828
}
811829
}

crates/pattern_discord/src/endpoints/discord.rs

Lines changed: 22 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -39,7 +39,7 @@ impl DiscordEndpoint {
3939
fn dm_tagged_content(content: &str, origin: Option<&MessageOrigin>) -> String {
4040
if let Some(MessageOrigin::Agent { name, .. }) = origin {
4141
// Subtle Markdown tag so recipients know which facet is speaking
42-
format!("*[{}]* {}", name, content)
42+
format!("*[{}]*\n{}", name, content)
4343
} else {
4444
content.to_string()
4545
}
@@ -515,7 +515,12 @@ impl DiscordEndpoint {
515515
}
516516

517517
/// Send a message to a specific Discord channel
518-
async fn send_to_channel(&self, channel_id: ChannelId, content: String) -> Result<()> {
518+
async fn send_to_channel(
519+
&self,
520+
channel_id: ChannelId,
521+
mut content: String,
522+
origin: Option<&MessageOrigin>,
523+
) -> Result<()> {
519524
info!(
520525
"send_to_channel called with content: '{}', is_reaction: {}",
521526
content,
@@ -599,6 +604,13 @@ impl DiscordEndpoint {
599604
}
600605
}
601606

607+
// If this is a DM channel, add facet tag for clarity
608+
if let Ok(channel) = channel_id.to_channel(&self.http).await {
609+
if matches!(channel, serenity::model::channel::Channel::Private(_)) {
610+
content = Self::dm_tagged_content(&content, origin);
611+
}
612+
}
613+
602614
// Fall back to sending as regular message with timeout
603615
match tokio::time::timeout(
604616
std::time::Duration::from_secs(10),
@@ -760,20 +772,20 @@ impl MessageEndpoint for DiscordEndpoint {
760772
"Failed to reply to message: {}, falling back to channel send",
761773
e
762774
);
763-
self.send_to_channel(channel, content).await?;
775+
self.send_to_channel(channel, content, origin).await?;
764776
} else {
765777
info!("Replied to message {} in channel {}", msg_id, channel_id);
766778
return Ok(Some(format!("reply:{}:{}", channel_id, msg_id)));
767779
}
768780
} else {
769781
// Can't find original message, just send to channel
770-
self.send_to_channel(channel, content).await?;
782+
self.send_to_channel(channel, content, origin).await?;
771783
}
772784
} else {
773-
self.send_to_channel(channel, content).await?;
785+
self.send_to_channel(channel, content, origin).await?;
774786
}
775787
} else {
776-
self.send_to_channel(channel, content).await?;
788+
self.send_to_channel(channel, content, origin).await?;
777789
}
778790
return Ok(Some(format!("channel:{}", channel_id)));
779791
}
@@ -797,7 +809,7 @@ impl MessageEndpoint for DiscordEndpoint {
797809
}
798810
}
799811
}
800-
self.send_to_channel(channel_id, content).await?;
812+
self.send_to_channel(channel_id, content, origin).await?;
801813
return Ok(Some(format!("channel:{}", channel_id)));
802814
}
803815

@@ -829,7 +841,7 @@ impl MessageEndpoint for DiscordEndpoint {
829841
}
830842
}
831843
}
832-
self.send_to_channel(ChannelId::new(channel_id), content)
844+
self.send_to_channel(ChannelId::new(channel_id), content, origin)
833845
.await?;
834846
return Ok(Some(format!("channel:{}", channel_id)));
835847
}
@@ -860,7 +872,7 @@ impl MessageEndpoint for DiscordEndpoint {
860872
{
861873
// Prefer channel if both are present (came from a channel message)
862874
if let Ok(chan_id) = channel_id.parse::<u64>() {
863-
self.send_to_channel(ChannelId::new(chan_id), content)
875+
self.send_to_channel(ChannelId::new(chan_id), content, origin)
864876
.await?;
865877
return Ok(Some(format!("channel:{}", chan_id)));
866878
} else if let Ok(usr_id) = user_id.parse::<u64>() {
@@ -879,7 +891,7 @@ impl MessageEndpoint for DiscordEndpoint {
879891

880892
// Fall back to default channel if configured
881893
if let Some(channel) = self.default_channel {
882-
self.send_to_channel(channel, content).await?;
894+
self.send_to_channel(channel, content, origin).await?;
883895
return Ok(Some(format!("default_channel:{}", channel)));
884896
}
885897

0 commit comments

Comments
 (0)