Skip to main content

polyc_agent/
participation.rs

1//! The provider-agnostic "should the agent speak?" classifier.
2//!
3//! A multi-party thread (a group chat channel, a group DM) is noisy: most messages
4//! are not for the agent, and a bot that replies to everything is worse than
5//! one that stays quiet. This module is the cheap triage gate that runs
6//! *before* the expensive agent turn: given the recent transcript, it asks a
7//! model for a one-word verdict — [`Verdict::Respond`], [`Verdict::Notify`],
8//! or [`Verdict::Ignore`] — and **defaults to silence** for anything it cannot
9//! confidently classify.
10//!
11//! It is deliberately provider-agnostic ([`classify_participation`] takes a
12//! generic `P: LlmProvider`) and lives in the agent crate, not any single edge
13//! adapter: the control plane hosts it over RPC, and adapters stay free of
14//! model/LLM logic.
15
16use polyc_llm::{CompletionRequest, Content, LlmProvider, Message, Role, turn::collect_turn};
17
18/// The triage outcome for the latest message in a thread.
19///
20/// Ordered from most to least engagement. [`Ignore`](Verdict::Ignore) is the
21/// safe default: the classifier returns it for anything it does not recognise
22/// as a clear `respond` or `notify`.
23#[derive(Debug, Clone, Copy, PartialEq, Eq)]
24pub enum Verdict {
25    /// The agent should reply in-thread — it was directly addressed or is
26    /// clearly the best party to help.
27    Respond,
28    /// Worth flagging to an operator, but the agent should not reply.
29    Notify,
30    /// Stay silent. The safe default.
31    Ignore,
32}
33
34/// One line of a multi-party thread, as seen by the classifier.
35#[derive(Debug, Clone)]
36pub struct ParticipationMsg {
37    /// Display name of whoever wrote the line.
38    pub speaker: String,
39    /// The message text.
40    pub text: String,
41    /// `true` when this line is one of the agent's own past messages.
42    pub is_self: bool,
43}
44
45/// System-prompt template for the triage gate. `{bot_name}` is the agent's name.
46fn system_prompt(bot_name: &str) -> String {
47    format!(
48        "You are {bot_name}, a participant in a multi-party Slack thread. Classify whether to \
49         engage with the LATEST message as exactly one of: respond, notify, ignore. Default to \
50         ignore. Choose respond only if you are directly addressed or are clearly the best party \
51         to help. Choose notify if the message is worth flagging to an operator but warrants no \
52         reply. If another human is already handling it, ignore. Answer with a single word: \
53         respond, notify, or ignore."
54    )
55}
56
57/// Render the transcript as `<speaker>: <text>`, one line per message, with the
58/// agent's own lines labelled as itself so the model knows which turns are its.
59fn render_transcript(bot_name: &str, transcript: &[ParticipationMsg]) -> String {
60    let mut out = String::new();
61    for msg in transcript {
62        let speaker = if msg.is_self { bot_name } else { &msg.speaker };
63        out.push_str(speaker);
64        out.push_str(": ");
65        out.push_str(&msg.text);
66        out.push('\n');
67    }
68    out
69}
70
71/// Parse a model reply into a [`Verdict`], case-insensitively.
72///
73/// `respond` wins over `notify` if both appear; anything unrecognised (and the
74/// empty string) falls through to [`Verdict::Ignore`] — silence is the safe
75/// default.
76fn parse_verdict(text: &str) -> Verdict {
77    let lower = text.to_lowercase();
78    if lower.contains("respond") {
79        Verdict::Respond
80    } else if lower.contains("notify") {
81        Verdict::Notify
82    } else {
83        Verdict::Ignore
84    }
85}
86
87/// Classify whether the agent should engage with the latest message in
88/// `transcript`.
89///
90/// Builds a [`CompletionRequest`] for `model` carrying a system message with
91/// the triage instructions and a user message with the rendered transcript,
92/// runs it through `provider`, folds the stream with [`collect_turn`], and
93/// parses the model's one-word reply. Returns [`Verdict::Ignore`] for any reply
94/// it cannot confidently read as `respond` or `notify`.
95///
96/// This is a *cheap triage gate* meant to run before the full agent turn; keep
97/// `model` pointed at a fast, inexpensive backend.
98///
99/// # Errors
100///
101/// Propagates `P::Error` from [`LlmProvider::complete`] (pre-stream failures)
102/// and from [`collect_turn`] (mid-stream faults).
103pub async fn classify_participation<P: LlmProvider + ?Sized>(
104    provider: &P,
105    model: &str,
106    bot_name: &str,
107    transcript: &[ParticipationMsg],
108) -> Result<Verdict, P::Error> {
109    let mut req = CompletionRequest::new(model);
110    req.messages.push(Message {
111        role: Role::System,
112        content: vec![Content::Text(system_prompt(bot_name))],
113    });
114    req.messages.push(Message {
115        role: Role::User,
116        content: vec![Content::Text(render_transcript(bot_name, transcript))],
117    });
118
119    let stream = provider.complete(req).await?;
120    let out = collect_turn(stream).await?;
121    Ok(parse_verdict(&out.text))
122}
123
124#[cfg(test)]
125mod tests {
126    #![allow(clippy::pedantic, clippy::nursery, missing_docs)]
127
128    use std::sync::{Arc, Mutex};
129
130    use async_trait::async_trait;
131    use futures::stream::{self, BoxStream, StreamExt};
132    use polyc_llm::{Chunk, StopReason, error::DummyError};
133
134    use super::*;
135
136    /// In-test provider that returns a configurable canned reply and captures
137    /// the request it was handed (so tests can assert on what was built).
138    #[derive(Clone)]
139    struct MockProvider {
140        reply: String,
141        captured: Arc<Mutex<Option<CompletionRequest>>>,
142    }
143
144    impl MockProvider {
145        fn new(reply: &str) -> Self {
146            Self {
147                reply: reply.to_owned(),
148                captured: Arc::new(Mutex::new(None)),
149            }
150        }
151    }
152
153    #[async_trait]
154    impl LlmProvider for MockProvider {
155        type Error = DummyError;
156
157        async fn complete(
158            &self,
159            req: CompletionRequest,
160        ) -> Result<BoxStream<'static, Result<Chunk, Self::Error>>, Self::Error> {
161            *self.captured.lock().unwrap() = Some(req);
162            let chunks = vec![
163                Ok(Chunk::text_delta(self.reply.clone())),
164                Ok(Chunk::Stop(StopReason::EndTurn)),
165            ];
166            Ok(stream::iter(chunks).boxed())
167        }
168    }
169
170    fn sample_transcript() -> Vec<ParticipationMsg> {
171        vec![
172            ParticipationMsg {
173                speaker: "alice".to_owned(),
174                text: "can someone deploy the build?".to_owned(),
175                is_self: false,
176            },
177            ParticipationMsg {
178                speaker: "bot".to_owned(),
179                text: "on it".to_owned(),
180                is_self: true,
181            },
182        ]
183    }
184
185    #[tokio::test]
186    async fn respond_reply_maps_to_respond() {
187        let provider = MockProvider::new("respond");
188        let verdict = classify_participation(&provider, "fast", "bot", &sample_transcript())
189            .await
190            .expect("classify");
191        assert_eq!(verdict, Verdict::Respond);
192    }
193
194    #[tokio::test]
195    async fn notify_reply_is_case_insensitive() {
196        let provider = MockProvider::new("NOTIFY please");
197        let verdict = classify_participation(&provider, "fast", "bot", &sample_transcript())
198            .await
199            .expect("classify");
200        assert_eq!(verdict, Verdict::Notify);
201    }
202
203    #[tokio::test]
204    async fn ignore_reply_maps_to_ignore() {
205        let provider = MockProvider::new("ignore");
206        let verdict = classify_participation(&provider, "fast", "bot", &sample_transcript())
207            .await
208            .expect("classify");
209        assert_eq!(verdict, Verdict::Ignore);
210    }
211
212    #[tokio::test]
213    async fn garbage_reply_defaults_to_ignore() {
214        let provider = MockProvider::new("\u{af}\\_(\u{30c4})_/\u{af} no idea");
215        let verdict = classify_participation(&provider, "fast", "bot", &sample_transcript())
216            .await
217            .expect("classify");
218        // Default-to-silence is the key invariant.
219        assert_eq!(verdict, Verdict::Ignore);
220    }
221
222    #[tokio::test]
223    async fn empty_reply_defaults_to_ignore() {
224        let provider = MockProvider::new("");
225        let verdict = classify_participation(&provider, "fast", "bot", &sample_transcript())
226            .await
227            .expect("classify");
228        // Default-to-silence is the key invariant.
229        assert_eq!(verdict, Verdict::Ignore);
230    }
231
232    #[tokio::test]
233    async fn request_carries_transcript_text() {
234        let provider = MockProvider::new("ignore");
235        let _ = classify_participation(&provider, "fast", "bot", &sample_transcript())
236            .await
237            .expect("classify");
238
239        let req = provider.captured.lock().unwrap().clone().expect("captured");
240        // A system message with triage instructions, then the transcript.
241        assert_eq!(req.messages.len(), 2);
242        assert_eq!(req.messages[0].role, Role::System);
243        assert_eq!(req.messages[1].role, Role::User);
244
245        let user_text = match &req.messages[1].content[0] {
246            Content::Text(t) => t.clone(),
247            other => panic!("expected text content, got {other:?}"),
248        };
249        assert!(user_text.contains("can someone deploy the build?"));
250        // The agent's own line is labelled as itself, not the raw speaker.
251        assert!(user_text.contains("bot: on it"));
252
253        let sys_text = match &req.messages[0].content[0] {
254            Content::Text(t) => t.clone(),
255            other => panic!("expected text content, got {other:?}"),
256        };
257        assert!(sys_text.contains("bot"));
258        assert!(sys_text.to_lowercase().contains("ignore"));
259    }
260}