Skip to content

Fix duplicate LLM responses for matched event routines - #1275

Merged
henrypark133 merged 5 commits into
stagingfrom
codex/routine-trigger-fix
Mar 18, 2026
Merged

henrypark133 merged 5 commits into
stagingfrom
codex/routine-trigger-fix

Conversation

@henrypark133

Copy link
Copy Markdown
Collaborator

Summary

  • consume matched event-triggered user messages before they enter the normal chat pipeline
  • prevent Telegram and gateway-style inbound matches from producing both a routine run and a separate assistant reply
  • update routine event trace coverage to assert the matched message only produces the routine-driven LLM turn

Testing

  • cargo test --features libsql routine_event_trigger_

Copilot AI review requested due to automatic review settings March 17, 2026 02:52
@github-actions github-actions Bot added scope: agent Agent core (agent loop, router, scheduler) size: M 50-199 changed lines risk: medium Business logic, config, or moderate-risk modules contributor: core 20+ merged PRs labels Mar 17, 2026
@gemini-code-assist

Copy link
Copy Markdown
Contributor

Summary of Changes

Hello, I'm Gemini Code Assist1! I'm currently reviewing this pull request and will post my feedback shortly. In the meantime, here's a summary to help you and other reviewers quickly get up to speed!

This pull request resolves an issue causing duplicate LLM responses when event-triggered routines are activated by inbound user messages. By introducing an early check for routine triggers and consuming the message if a routine fires, the system now ensures that only the routine-driven response is generated. This change streamlines the interaction flow, prevents redundant replies from the main agent, and improves the overall consistency of the agent's behavior.

Highlights

  • Early Message Consumption: Implemented logic to consume user input messages early in the agent loop if they trigger an event-driven routine, preventing further processing by the main agent.
  • Duplicate Response Prevention: Eliminated scenarios where both the main agent and an event-triggered routine would respond to the same inbound message, ensuring a single, consistent LLM turn.
  • Test Coverage Enhancement: Updated end-to-end tests to specifically assert that only a single LLM call and response occur when an event-triggered routine is matched, validating the fix.
Changelog
  • src/agent/agent_loop.rs
    • Added a new conditional block to Agent::run_loop that checks for event-triggered routines on non-internal user input messages. If a routine fires, the message is consumed, and the loop continues to the next message.
    • Removed the previous, general check_event_triggers call that occurred later in the Agent::run_loop method.
  • tests/e2e_advanced_traces.rs
    • Introduced rig.clear().await and llm_calls_before tracking before sending incoming messages to ensure accurate LLM call counts.
    • Added assertions to verify that llm_call_count() increases by exactly one after an event-triggered routine fires, confirming only the routine's LLM call.
    • Modified rig.wait_for_responses to expect only one response and added an assertion to confirm responses.len() is 1, preventing multiple responses.
    • Updated an assertion to check for specific content in the single expected routine notification.
  • tests/fixtures/llm_traces/advanced/routine_event_any_channel.json
    • Removed a redundant LLM response entry, reflecting the fix for duplicate responses.
  • tests/fixtures/llm_traces/advanced/routine_event_telegram.json
    • Removed a redundant LLM response entry, reflecting the fix for duplicate responses.
Activity
  • The author, henrypark133, has provided detailed testing instructions, including a specific cargo test command to verify the fix: cargo test --features libsql routine_event_trigger_.
Using Gemini Code Assist

The full guide for Gemini Code Assist can be found on our documentation page, here are some quick tips.

Invoking Gemini

You can request assistance from Gemini at any point by creating a comment using either /gemini <command> or @gemini-code-assist <command>. Below is a summary of the supported commands on the current page.

Feature Command Description
Code Review /gemini review Performs a code review for the current pull request in its current state.
Pull Request Summary /gemini summary Provides a summary of the current pull request in its current state.
Comment @gemini-code-assist Responds in comments when explicitly tagged, both in pull request comments and review comments.
Help /gemini help Displays a list of available commands.

Customization

To customize Gemini Code Assist for GitHub experience, repository maintainers can create a configuration file and/or provide a custom code review style guide (such as PEP-8 for Python) by creating and adding files to a .gemini/ folder in the base of the repository. Detailed instructions can be found here.

Limitations & Feedback

Gemini Code Assist may make mistakes. Please leave feedback on any instances where its feedback is incorrect or counter productive. You can react with 👍 and 👎 on @gemini-code-assist comments. If you're interested in giving your feedback about your experience with Gemini Code Assist for GitHub and other Google products, sign up here.

You can also get AI-powered code generation, chat, as well as code reviews directly in the IDE at no cost with the Gemini Code Assist IDE Extension.

Footnotes

  1. Review the Privacy Notices, Generative AI Prohibited Use Policy, Terms of Service, and learn how to configure Gemini Code Assist in GitHub here. Gemini can make mistakes, so double check it and use code with caution. ↩

@gemini-code-assist gemini-code-assist Bot left a comment

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.

Code Review

This pull request addresses an issue where messages triggering event-based routines would also be processed by the main chat pipeline, resulting in duplicate responses. The fix is implemented by checking for and consuming event-triggered user messages before they enter the standard message handling logic. If a routine is fired, the agent loop continues to the next message, preventing a duplicate agent reply. The end-to-end tests and corresponding LLM trace fixtures have been updated to assert that only the routine-driven LLM turn occurs for a matched message, confirming the fix.

Copilot AI left a comment

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.

Pull request overview

This PR fixes a duplicate-response behavior where a single inbound message that matches an event-triggered routine could both (1) fire the routine and (2) still flow through the normal agent chat pipeline, producing an extra assistant reply.

Changes:

  • Consume inbound event-matching user messages earlier to prevent the main chat/tool pipeline from generating a second assistant response.
  • Update E2E trace fixtures to remove the extra (duplicate) assistant response step.
  • Strengthen E2E assertions to verify only the routine-driven LLM call occurs and only the routine notification is emitted.

Reviewed changes

Copilot reviewed 4 out of 4 changed files in this pull request and generated 2 comments.

File Description
src/agent/agent_loop.rs Adds early event-trigger checking and skips the normal message handling when an event routine fires, aiming to prevent duplicate responses.
tests/e2e_advanced_traces.rs Updates event-trigger tests to assert exactly one additional LLM call and a single routine notification response after a matching inbound message.
tests/fixtures/llm_traces/advanced/routine_event_telegram.json Removes the previously-recorded extra assistant response from the trace fixture.
tests/fixtures/llm_traces/advanced/routine_event_any_channel.json Removes the previously-recorded extra assistant response from the trace fixture.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread src/agent/agent_loop.rs Outdated
Comment thread src/agent/agent_loop.rs Outdated

Copilot AI left a comment

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.

Pull request overview

This PR fixes a behavior where an inbound message that matches an event-triggered routine could produce both (1) a routine-driven LLM turn/notification and (2) a separate assistant reply from the normal chat pipeline. It does this by consuming matched event-triggered user messages during handle_message() so they don’t proceed through the standard response path, and updates trace fixtures/tests accordingly.

Changes:

  • Move event-trigger checking into handle_message() and suppress the normal assistant reply by returning an empty response when event routines fire.
  • Add LLM-call-count and response-count assertions to ensure a matched event message produces only the routine-driven LLM call/notification.
  • Update LLM trace fixtures to remove the previously duplicated assistant reply.

Reviewed changes

Copilot reviewed 4 out of 4 changed files in this pull request and generated 2 comments.

File Description
src/agent/agent_loop.rs Consumes matched event-trigger messages inside handle_message() by firing routines and returning an empty response to prevent duplicate assistant replies.
tests/e2e_advanced_traces.rs Strengthens E2E assertions to ensure only one LLM call and one routine notification occur for matched event messages.
tests/fixtures/llm_traces/advanced/routine_event_telegram.json Removes the extra assistant response from the expected trace for Telegram-scoped event routines.
tests/fixtures/llm_traces/advanced/routine_event_any_channel.json Removes the extra assistant response from the expected trace for any-channel event routines.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread src/agent/agent_loop.rs Outdated
Comment on lines +945 to +957
if matches!(submission, Submission::UserInput { .. })
&& let Some(engine) = self.routine_engine().await
{
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)"
);
return Ok(Some(String::new()));
}

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Fixed in 89320c0. routine_trigger_message() now extracts the post-hook content from the parsed Submission::UserInput { content } and, when it differs from the original message.content, clones the message with the rewritten content (via Cow::Owned). When content is unchanged, Cow::Borrowed avoids any allocation. The trigger check now consistently matches against what the agent will actually process.

Comment thread src/agent/agent_loop.rs Outdated
Comment on lines +182 to +197
@@ -191,6 +191,11 @@ impl Agent {
self.routine_engine_slot = Some(slot);
}

async fn routine_engine(&self) -> Option<Arc<crate::agent::routine_engine::RoutineEngine>> {
let slot = self.routine_engine_slot.as_ref()?;
slot.read().await.clone()
}
zmanian
zmanian previously approved these changes Mar 17, 2026

@zmanian zmanian left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Review: fix duplicate LLM responses for event-triggered routines

Clean, well-structured fix. The progression through 4 commits shows good iteration:

  1. Initial fix (consume before handle_message)
  2. Rustfmt
  3. Move check inside handle_message to preserve preprocessing
  4. Match against rewritten input using Cow for efficiency

The final approach is correct -- checking event triggers inside handle_message (after submission parsing) ensures routines fire against preprocessed content and that the message is consumed before the main chat pipeline processes it.

Positives:

  • routine_trigger_message() helper uses Cow nicely to avoid cloning when content is unchanged
  • Test coverage is solid: unit tests for the helper (borrowed, owned, internal skip), updated integration tests asserting only 1 response and 1 LLM call, fixture traces updated
  • The routine_engine_slot initialization simplification is clean
  • CI fully green

Minor notes:

  • Returning Ok(Some(String::new())) relies on the caller's !response.is_empty() guard to skip empty responses -- this coupling is correct but implicit. A future refactor might consider Ok(None) for "consumed, no response needed" but that's outside scope.

LGTM.

Copilot AI review requested due to automatic review settings March 18, 2026 16:16
@henrypark133
henrypark133 force-pushed the codex/routine-trigger-fix branch from 18a6fc2 to 89320c0 Compare March 18, 2026 16:16

Copilot AI left a comment

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.

Pull request overview

This PR prevents duplicate assistant turns when an inbound user message matches an event-triggered routine by consuming the message inside handle_message() (after standard preprocessing) and returning an empty response.

Changes:

  • Adds a helper to derive the routine-trigger “effective message” from the parsed Submission (including hook-modified user input).
  • Moves event-trigger consumption into handle_message() and suppresses the normal assistant reply when routines fire.
  • Adds unit tests validating the trigger-message borrowing/rewriting behavior and internal-message exclusion.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread src/agent/agent_loop.rs Outdated
Comment thread src/agent/agent_loop.rs Outdated
…_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) <noreply@anthropic.com>
bkutasi pushed a commit to bkutasi/ironclaw that referenced this pull request Mar 28, 2026
* 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) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
drchirag1991 pushed a commit to drchirag1991/ironclaw that referenced this pull request Apr 8, 2026
* 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) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

contributor: core 20+ merged PRs risk: medium Business logic, config, or moderate-risk modules scope: agent Agent core (agent loop, router, scheduler) size: M 50-199 changed lines

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants