Skip to content

Commit c375803

Browse files
committed
fix(memory): address participant context review feedback
1 parent 403b957 commit c375803

3 files changed

Lines changed: 175 additions & 47 deletions

File tree

src/agent/channel.rs

Lines changed: 110 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -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+
89108
fn 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

558602
impl 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)]
37403784
mod 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]

src/api/channels.rs

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -612,7 +612,14 @@ pub(super) async fn inspect_prompt(
612612
&participant_config,
613613
)
614614
.await
615-
.unwrap_or_default();
615+
.unwrap_or_else(|error| {
616+
tracing::warn!(
617+
%error,
618+
channel_id = %query.channel_id,
619+
"failed to render participant context for prompt inspection"
620+
);
621+
String::new()
622+
});
616623

617624
// ── Available channels ──
618625
let available_channels = {

src/conversation/participants.rs

Lines changed: 57 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,8 @@ use chrono::{DateTime, Utc};
55

66
use std::collections::HashMap;
77

8+
const MAX_DISPLAY_NAME_CHARS: usize = 80;
9+
810
/// Active participant state tracked for a live channel session.
911
///
1012
/// This is the bridge between today's lightweight config-backed participant
@@ -59,9 +61,7 @@ pub fn track_active_participant(
5961
let human = find_human_for_sender(humans, platform, &message.sender_id);
6062
let display_name = human
6163
.and_then(|entry| entry.display_name.as_deref())
62-
.map(str::trim)
63-
.filter(|name| !name.is_empty())
64-
.map(ToOwned::to_owned)
64+
.and_then(sanitize_display_name)
6565
.unwrap_or_else(|| participant_display_name(message));
6666
let participant_key = participant_memory_key(humans, platform, &message.sender_id);
6767
let fallback_key = format!("{platform}:{}", message.sender_id);
@@ -112,23 +112,45 @@ pub fn renderable_participants(
112112
/// Best-effort display name for an inbound message sender.
113113
pub fn participant_display_name(message: &InboundMessage) -> String {
114114
message
115-
.metadata
116-
.get("sender_display_name")
117-
.and_then(|value| value.as_str())
118-
.map(str::trim)
119-
.filter(|name| !name.is_empty())
120-
.map(ToOwned::to_owned)
115+
.formatted_author
116+
.as_deref()
117+
.and_then(sanitize_display_name)
121118
.or_else(|| {
122119
message
123-
.formatted_author
124-
.as_deref()
125-
.map(str::trim)
126-
.filter(|name| !name.is_empty())
127-
.map(ToOwned::to_owned)
120+
.metadata
121+
.get("sender_display_name")
122+
.and_then(|value| value.as_str())
123+
.and_then(sanitize_display_name)
128124
})
125+
.or_else(|| sanitize_display_name(&message.sender_id))
129126
.unwrap_or_else(|| message.sender_id.clone())
130127
}
131128

129+
fn sanitize_display_name(value: &str) -> Option<String> {
130+
let collapsed = value
131+
.chars()
132+
.map(|character| {
133+
if character.is_control() {
134+
' '
135+
} else {
136+
character
137+
}
138+
})
139+
.collect::<String>();
140+
let mut sanitized = collapsed.split_whitespace().collect::<Vec<_>>().join(" ");
141+
if sanitized.is_empty() {
142+
return None;
143+
}
144+
145+
if sanitized.len() > MAX_DISPLAY_NAME_CHARS {
146+
let boundary = sanitized.floor_char_boundary(MAX_DISPLAY_NAME_CHARS);
147+
sanitized.truncate(boundary);
148+
sanitized.push_str("...");
149+
}
150+
151+
Some(sanitized)
152+
}
153+
132154
fn find_human_for_sender<'a>(
133155
humans: &'a [HumanDef],
134156
platform: &str,
@@ -259,6 +281,27 @@ mod tests {
259281
);
260282
}
261283

284+
#[test]
285+
fn participant_display_name_prefers_formatted_author() {
286+
let mut message = test_message();
287+
message.formatted_author = Some("Victor Summit".to_string());
288+
289+
assert_eq!(participant_display_name(&message), "Victor Summit");
290+
}
291+
292+
#[test]
293+
fn participant_display_name_sanitizes_user_input() {
294+
let mut message = test_message();
295+
message.formatted_author = Some(" Victor\n\t<System>\u{0007} ".repeat(10));
296+
297+
let display_name = participant_display_name(&message);
298+
assert!(display_name.starts_with("Victor <System>"));
299+
assert!(display_name.ends_with("..."));
300+
assert!(display_name.len() <= MAX_DISPLAY_NAME_CHARS + 3);
301+
assert!(!display_name.contains('\n'));
302+
assert!(!display_name.contains('\u{0007}'));
303+
}
304+
262305
#[test]
263306
fn upgrades_fallback_participant_entry_without_duplicates() {
264307
let mut participants = HashMap::new();

0 commit comments

Comments
 (0)