Fix duplicate LLM responses for matched event routines (#1275)

* fix: consume matched event routine messages

* style: run rustfmt for event routine fix

* fix: preserve preprocessing for routine-triggered messages

* fix: match routines against rewritten input

* refactor: narrow check_event_triggers API and simplify routine_engine_slot

Address Copilot review feedback:

- Change check_event_triggers to accept (user_id, channel, content) instead
  of &IncomingMessage, eliminating the need to clone the full message
  (including attachments) when hooks rewrite content.

- Remove routine_trigger_message and the Cow<IncomingMessage> indirection;
  the event-trigger check now inlines the is_internal + UserInput guard and
  passes the post-hook content string directly.

- Make routine_engine_slot non-optional since Agent::new() always
  initializes it. Removes the redundant Option wrapper and simplifies
  accessor/setter methods.

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>

---------

Co-authored-by: Claude Opus 4.6 (1M context) <[email protected]>
This commit is contained in:
Henry Park
2026-03-18 12:29:35 -07:00
committed by GitHub
co-authored by Claude Opus 4.6
parent f2cd1d37bc
commit 20202700db
3 changed files with 77 additions and 52 deletions
+30 -34
View File
@@ -161,9 +161,10 @@ pub struct Agent {
pub(super) heartbeat_config: Option<HeartbeatConfig>,
pub(super) hygiene_config: Option<crate::config::HygieneConfig>,
pub(super) routine_config: Option<RoutineConfig>,
/// Optional slot to expose the routine engine to the gateway for manual triggering.
/// Shared routine-engine slot used for internal event matching and for exposing
/// the engine to gateway/manual trigger entry points.
pub(super) routine_engine_slot:
Option<Arc<tokio::sync::RwLock<Option<Arc<crate::agent::routine_engine::RoutineEngine>>>>>,
Arc<tokio::sync::RwLock<Option<Arc<crate::agent::routine_engine::RoutineEngine>>>>,
}
impl Agent {
@@ -228,16 +229,21 @@ impl Agent {
heartbeat_config,
hygiene_config,
routine_config,
routine_engine_slot: None,
routine_engine_slot: Arc::new(tokio::sync::RwLock::new(None)),
}
}
/// Set the routine engine slot for exposing the engine to the gateway.
/// Replace the routine-engine slot with a shared one so the gateway and
/// agent reference the same engine.
pub fn set_routine_engine_slot(
&mut self,
slot: Arc<tokio::sync::RwLock<Option<Arc<crate::agent::routine_engine::RoutineEngine>>>>,
) {
self.routine_engine_slot = Some(slot);
self.routine_engine_slot = slot;
}
async fn routine_engine(&self) -> Option<Arc<crate::agent::routine_engine::RoutineEngine>> {
self.routine_engine_slot.read().await.clone()
}
// Convenience accessors
@@ -633,9 +639,7 @@ impl Agent {
// via a local to use in the message loop below.
// Expose engine to gateway for manual triggering
if let Some(ref slot) = self.routine_engine_slot {
*slot.write().await = Some(Arc::clone(&engine));
}
*self.routine_engine_slot.write().await = Some(Arc::clone(&engine));
tracing::debug!(
"Routines enabled: cron ticker every {}s, max {} concurrent",
@@ -655,9 +659,6 @@ impl Agent {
None
};
// Extract engine ref for use in message loop
let routine_engine_for_loop = routine_handle.as_ref().map(|(_, e)| Arc::clone(e));
// Main message loop
tracing::debug!("Agent {} ready and listening", self.config.name);
@@ -693,29 +694,6 @@ impl Agent {
// Store successfully extracted document text in workspace for indexing
self.store_extracted_documents(&message).await;
// Event-triggered routines consume plain user input before it enters
// the normal chat/tool pipeline. This avoids a duplicate turn where
// the main agent responds and the routine also fires on the same
// inbound message.
if !message.is_internal
&& matches!(
SubmissionParser::parse(&message.content),
Submission::UserInput { .. }
)
&& let Some(ref engine) = routine_engine_for_loop
{
let fired = engine.check_event_triggers(&message).await;
if fired > 0 {
tracing::debug!(
channel = %message.channel,
user = %message.user_id,
fired,
"Consumed inbound user message with matching event-triggered routine(s)"
);
continue;
}
}
match self.handle_message(&message).await {
Ok(Some(response)) if !response.is_empty() => {
// Hook: BeforeOutbound — allow hooks to modify or suppress outbound
@@ -1032,6 +1010,24 @@ impl Agent {
message.content.len()
);
if !message.is_internal
&& let Submission::UserInput { ref content } = submission
&& let Some(engine) = self.routine_engine().await
{
let fired = engine
.check_event_triggers(&message.user_id, &message.channel, content)
.await;
if fired > 0 {
tracing::debug!(
channel = %message.channel,
user = %message.user_id,
fired,
"Consumed inbound user message with matching event-triggered routine(s)"
);
return Ok(Some(String::new()));
}
}
// Process based on submission type
let result = match submission {
Submission::UserInput { content } => {
+8 -8
View File
@@ -23,7 +23,7 @@ use crate::agent::Scheduler;
use crate::agent::routine::{
NotifyConfig, Routine, RoutineAction, RoutineRun, RunStatus, Trigger, next_cron_fire,
};
use crate::channels::{IncomingMessage, OutgoingResponse};
use crate::channels::OutgoingResponse;
use crate::config::RoutineConfig;
use crate::context::JobContext;
use crate::db::Database;
@@ -135,9 +135,9 @@ impl RoutineEngine {
/// Check incoming message against event triggers. Returns number of routines fired.
///
/// Called synchronously from the main loop after handle_message(). The actual
/// execution is spawned async so this returns quickly.
pub async fn check_event_triggers(&self, message: &IncomingMessage) -> usize {
/// Accepts only the three fields needed for matching (user scope, channel,
/// message content) so callers never need to clone a full `IncomingMessage`.
pub async fn check_event_triggers(&self, user_id: &str, channel: &str, content: &str) -> usize {
let cache = self.event_cache.read().await;
let mut fired = 0;
@@ -173,7 +173,7 @@ impl RoutineEngine {
EventMatcher::System { .. } => continue,
};
if routine.user_id != message.user_id {
if routine.user_id != user_id {
continue;
}
@@ -181,13 +181,13 @@ impl RoutineEngine {
if let Trigger::Event {
channel: Some(ch), ..
} = &routine.trigger
&& ch != &message.channel
&& ch != channel
{
continue;
}
// Regex match
if !re.is_match(&message.content) {
if !re.is_match(content) {
continue;
}
@@ -210,7 +210,7 @@ impl RoutineEngine {
continue;
}
let detail = truncate(&message.content, 200);
let detail = truncate(content, 200);
self.spawn_fire(routine.clone(), "event", Some(detail));
fired += 1;
}
+39 -10
View File
@@ -238,7 +238,13 @@ mod tests {
"default",
"deploy to production now",
);
let fired = engine.check_event_triggers(&matching_msg).await;
let fired = engine
.check_event_triggers(
&matching_msg.user_id,
&matching_msg.channel,
&matching_msg.content,
)
.await;
assert!(
fired >= 1,
"Expected >= 1 routine fired on match, got {fired}"
@@ -255,7 +261,13 @@ mod tests {
"default",
"check the staging environment",
);
let fired_neg = engine.check_event_triggers(&non_matching_msg).await;
let fired_neg = engine
.check_event_triggers(
&non_matching_msg.user_id,
&non_matching_msg.channel,
&non_matching_msg.content,
)
.await;
assert_eq!(fired_neg, 0, "Expected 0 routines fired on non-match");
}
@@ -315,7 +327,9 @@ mod tests {
"guest-sender",
"deploy to production now",
);
let guest_fired = engine.check_event_triggers(&guest_msg).await;
let guest_fired = engine
.check_event_triggers(&guest_msg.user_id, &guest_msg.channel, &guest_msg.content)
.await;
assert_eq!(
guest_fired, 0,
"Guest scope must not fire owner event routines"
@@ -338,7 +352,9 @@ mod tests {
"owner-sender",
"deploy to production now",
);
let owner_fired = engine.check_event_triggers(&owner_msg).await;
let owner_fired = engine
.check_event_triggers(&owner_msg.user_id, &owner_msg.channel, &owner_msg.content)
.await;
assert!(
owner_fired >= 1,
"Owner scope should fire matching owner event routine"
@@ -562,7 +578,9 @@ mod tests {
"default",
"test-cooldown trigger",
);
let fired1 = engine.check_event_triggers(&msg).await;
let fired1 = engine
.check_event_triggers(&msg.user_id, &msg.channel, &msg.content)
.await;
assert!(fired1 >= 1, "First fire should work");
// Give spawn time, then update last_run_at to simulate recent execution.
@@ -577,7 +595,9 @@ mod tests {
engine.refresh_event_cache().await;
// Second fire should be blocked by cooldown.
let fired2 = engine.check_event_triggers(&msg).await;
let fired2 = engine
.check_event_triggers(&msg.user_id, &msg.channel, &msg.content)
.await;
assert_eq!(fired2, 0, "Second fire should be blocked by cooldown");
}
@@ -745,7 +765,9 @@ mod tests {
engine.refresh_event_cache().await;
let msg = IncomingMessage::new("test", "default", "DISABLE_ME");
let fired_before = engine.check_event_triggers(&msg).await;
let fired_before = engine
.check_event_triggers(&msg.user_id, &msg.channel, &msg.content)
.await;
assert!(fired_before >= 1, "Expected routine to fire before disable");
// Simulate what routines_toggle_handler now does: update DB, then refresh.
@@ -754,7 +776,9 @@ mod tests {
db.update_routine(&routine).await.expect("update_routine");
engine.refresh_event_cache().await;
let fired_after = engine.check_event_triggers(&msg).await;
let fired_after = engine
.check_event_triggers(&msg.user_id, &msg.channel, &msg.content)
.await;
assert_eq!(
fired_after, 0,
"Disabled routine must not fire after cache refresh"
@@ -780,7 +804,10 @@ mod tests {
let msg = IncomingMessage::new("test", "default", "DELETE_ME");
assert!(
engine.check_event_triggers(&msg).await >= 1,
engine
.check_event_triggers(&msg.user_id, &msg.channel, &msg.content)
.await
>= 1,
"Expected routine to fire before delete"
);
@@ -789,7 +816,9 @@ mod tests {
engine.refresh_event_cache().await;
assert_eq!(
engine.check_event_triggers(&msg).await,
engine
.check_event_triggers(&msg.user_id, &msg.channel, &msg.content)
.await,
0,
"Deleted routine must not fire after cache refresh"
);