@@ -86,6 +86,25 @@ fn should_flush_coalesce_buffer_for_event(event: &ProcessEvent) -> bool {
8686 )
8787}
8888
89+ fn sentence_contains_decision_marker ( sentence : & str , explicit_markers : & [ & str ] ) -> bool {
90+ let sentence_lower = sentence. to_ascii_lowercase ( ) ;
91+ explicit_markers
92+ . iter ( )
93+ . any ( |marker| sentence_lower. contains ( marker) )
94+ || ( sentence_lower. contains ( " instead of " )
95+ && [
96+ "use " ,
97+ "switch" ,
98+ "adopt " ,
99+ "choose " ,
100+ "pick " ,
101+ "go with " ,
102+ "proceed with " ,
103+ ]
104+ . iter ( )
105+ . any ( |marker| sentence_lower. contains ( marker) ) )
106+ }
107+
89108fn extract_decision_summary_from_reply ( reply_text : & str ) -> Option < String > {
90109 let normalized = reply_text. split_whitespace ( ) . collect :: < Vec < _ > > ( ) . join ( " " ) ;
91110 let trimmed = normalized. trim ( ) ;
@@ -136,9 +155,17 @@ fn extract_decision_summary_from_reply(reply_text: &str) -> Option<String> {
136155 return None ;
137156 }
138157
139- let mut summary = trimmed
158+ let sentences : Vec < & str > = trimmed
140159 . split_terminator ( [ '.' , '!' , '?' , '\n' ] )
141- . find ( |sentence| !sentence. trim ( ) . is_empty ( ) )
160+ . map ( str:: trim)
161+ . filter ( |sentence| !sentence. is_empty ( ) )
162+ . collect ( ) ;
163+
164+ let mut summary = sentences
165+ . iter ( )
166+ . copied ( )
167+ . find ( |sentence| sentence_contains_decision_marker ( sentence, & explicit_markers) )
168+ . or_else ( || sentences. first ( ) . copied ( ) )
142169 . unwrap_or ( trimmed)
143170 . trim ( )
144171 . to_string ( ) ;
@@ -152,6 +179,23 @@ fn extract_decision_summary_from_reply(reply_text: &str) -> Option<String> {
152179 Some ( summary)
153180}
154181
182+ fn decision_user_id (
183+ humans : & [ crate :: config:: HumanDef ] ,
184+ message : & InboundMessage ,
185+ is_retrigger : bool ,
186+ ) -> Option < String > {
187+ if is_retrigger || message. source == "system" {
188+ return None ;
189+ }
190+
191+ let source = message. source . trim ( ) ;
192+ if source. is_empty ( ) || message. sender_id . is_empty ( ) {
193+ return None ;
194+ }
195+
196+ Some ( participant_memory_key ( humans, source, & message. sender_id ) )
197+ }
198+
155199/// Shared state that channel tools need to act on the channel.
156200///
157201/// Wrapped in Arc and passed to tools (branch, spawn_worker, route, cancel)
@@ -556,6 +600,27 @@ impl Drop for MessageDurationGuard {
556600}
557601
558602impl Channel {
603+ fn record_decision_event ( & self , reply_text : Option < & str > , user_id : Option < String > ) {
604+ let Some ( decision_summary) = reply_text. and_then ( extract_decision_summary_from_reply)
605+ else {
606+ return ;
607+ } ;
608+
609+ let mut event = self
610+ . deps
611+ . working_memory
612+ . emit (
613+ crate :: memory:: WorkingMemoryEventType :: Decision ,
614+ decision_summary,
615+ )
616+ . channel ( self . id . as_ref ( ) )
617+ . importance ( 0.8 ) ;
618+ if let Some ( user_id) = user_id {
619+ event = event. user ( user_id) ;
620+ }
621+ event. record ( ) ;
622+ }
623+
559624 /// Create a new channel.
560625 ///
561626 /// All tunable config (prompts, routing, thresholds, browser, skills) is read
@@ -1587,7 +1652,7 @@ impl Channel {
15871652 }
15881653
15891654 // Run agent turn with any image/audio attachments preserved
1590- let ( result, skip_flag, replied_flag, _, _ ) = self
1655+ let ( result, skip_flag, replied_flag, _, reply_text ) = self
15911656 . run_agent_turn (
15921657 & combined_text,
15931658 & system_prompt,
@@ -1600,6 +1665,9 @@ impl Channel {
16001665
16011666 self . handle_agent_result ( result, & skip_flag, & replied_flag, false )
16021667 . await ;
1668+ if replied_flag. load ( std:: sync:: atomic:: Ordering :: Relaxed ) {
1669+ self . record_decision_event ( reply_text. as_deref ( ) , None ) ;
1670+ }
16031671 // Check compaction
16041672 if let Err ( error) = self . compactor . check_and_compact ( ) . await {
16051673 tracing:: warn!( channel_id = %self . id, %error, "compaction check failed" ) ;
@@ -1954,34 +2022,10 @@ impl Channel {
19542022 self . handle_agent_result ( result, & skip_flag, & replied_flag, is_retrigger)
19552023 . await ;
19562024
1957- if replied_flag. load ( std:: sync:: atomic:: Ordering :: Relaxed )
1958- && let Some ( decision_summary) = reply_text
1959- . as_deref ( )
1960- . and_then ( extract_decision_summary_from_reply)
1961- {
1962- let user_id = if message. source . trim ( ) . is_empty ( ) || message. sender_id . is_empty ( ) {
1963- None
1964- } else {
1965- let humans = self . deps . humans . load ( ) ;
1966- Some ( participant_memory_key (
1967- humans. as_ref ( ) ,
1968- message. source . trim ( ) ,
1969- & message. sender_id ,
1970- ) )
1971- } ;
1972- let mut event = self
1973- . deps
1974- . working_memory
1975- . emit (
1976- crate :: memory:: WorkingMemoryEventType :: Decision ,
1977- decision_summary,
1978- )
1979- . channel ( self . id . as_ref ( ) )
1980- . importance ( 0.8 ) ;
1981- if let Some ( user_id) = user_id {
1982- event = event. user ( user_id) ;
1983- }
1984- event. record ( ) ;
2025+ if replied_flag. load ( std:: sync:: atomic:: Ordering :: Relaxed ) {
2026+ let humans = self . deps . humans . load ( ) ;
2027+ let user_id = decision_user_id ( humans. as_ref ( ) , & message, is_retrigger) ;
2028+ self . record_decision_event ( reply_text. as_deref ( ) , user_id) ;
19852029 }
19862030
19872031 // Safety-net: in quiet mode, explicit mention/reply should never be dropped silently.
@@ -3739,7 +3783,7 @@ fn is_dm_conversation_id(conv_id: &str) -> bool {
37393783#[ cfg( test) ]
37403784mod tests {
37413785 use super :: {
3742- QuietModeFallbackState , compute_listen_mode_invocation,
3786+ QuietModeFallbackState , compute_listen_mode_invocation, decision_user_id ,
37433787 extract_decision_summary_from_reply, is_dm_conversation_id, recv_channel_event,
37443788 should_process_event_for_channel, should_send_discord_quiet_mode_ping_ack,
37453789 should_send_quiet_mode_fallback,
@@ -3851,6 +3895,40 @@ mod tests {
38513895 )
38523896 . is_none( )
38533897 ) ;
3898+ assert_eq ! (
3899+ extract_decision_summary_from_reply( "Got it. We'll switch to the new routing config." )
3900+ . as_deref( ) ,
3901+ Some ( "We'll switch to the new routing config" )
3902+ ) ;
3903+ }
3904+
3905+ #[ test]
3906+ fn decision_user_id_skips_retrigger_messages ( ) {
3907+ let humans = vec ! [ crate :: config:: HumanDef {
3908+ id: "victor" . to_string( ) ,
3909+ display_name: Some ( "Victor" . to_string( ) ) ,
3910+ role: None ,
3911+ bio: None ,
3912+ description: None ,
3913+ discord_id: Some ( "12345" . to_string( ) ) ,
3914+ telegram_id: None ,
3915+ slack_id: None ,
3916+ email: None ,
3917+ } ] ;
3918+ let message = InboundMessage {
3919+ id : "message-1" . to_string ( ) ,
3920+ source : "system" . to_string ( ) ,
3921+ adapter : None ,
3922+ conversation_id : "discord:chan-1" . to_string ( ) ,
3923+ sender_id : "12345" . to_string ( ) ,
3924+ agent_id : None ,
3925+ content : crate :: MessageContent :: Text ( "retrigger" . to_string ( ) ) ,
3926+ timestamp : chrono:: Utc :: now ( ) ,
3927+ metadata : HashMap :: new ( ) ,
3928+ formatted_author : None ,
3929+ } ;
3930+
3931+ assert ! ( decision_user_id( & humans, & message, true ) . is_none( ) ) ;
38543932 }
38553933
38563934 #[ test]
0 commit comments