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. An empty `model` defers
98/// to the provider's configured default; a non-empty value overrides it
99/// per-request (it reaches the backend as a model id — never pass a label).
100///
101/// # Errors
102///
103/// Propagates `P::Error` from [`LlmProvider::complete`] (pre-stream failures)
104/// and from [`collect_turn`] (mid-stream faults).
105pub async fn classify_participation<P: LlmProvider + ?Sized>(
106    provider: &P,
107    model: &str,
108    bot_name: &str,
109    transcript: &[ParticipationMsg],
110) -> Result<Verdict, P::Error> {
111    let mut req = CompletionRequest::new(model);
112    req.messages.push(Message {
113        role: Role::System,
114        content: vec![Content::Text(system_prompt(bot_name))],
115    });
116    req.messages.push(Message {
117        role: Role::User,
118        content: vec![Content::Text(render_transcript(bot_name, transcript))],
119    });
120
121    let stream = provider.complete(req).await?;
122    let out = collect_turn(stream).await?;
123    Ok(parse_verdict(&out.text))
124}
125
126#[cfg(test)]
127mod tests {
128    #![allow(clippy::pedantic, clippy::nursery, missing_docs)]
129
130    use std::sync::{Arc, Mutex};
131
132    use async_trait::async_trait;
133    use futures::stream::{self, BoxStream, StreamExt};
134    use polyc_llm::{Chunk, StopReason, error::DummyError};
135
136    use super::*;
137
138    /// In-test provider that returns a configurable canned reply and captures
139    /// the request it was handed (so tests can assert on what was built).
140    #[derive(Clone)]
141    struct MockProvider {
142        reply: String,
143        captured: Arc<Mutex<Option<CompletionRequest>>>,
144    }
145
146    impl MockProvider {
147        fn new(reply: &str) -> Self {
148            Self {
149                reply: reply.to_owned(),
150                captured: Arc::new(Mutex::new(None)),
151            }
152        }
153    }
154
155    #[async_trait]
156    impl LlmProvider for MockProvider {
157        type Error = DummyError;
158
159        async fn complete(
160            &self,
161            req: CompletionRequest,
162        ) -> Result<BoxStream<'static, Result<Chunk, Self::Error>>, Self::Error> {
163            *self.captured.lock().unwrap() = Some(req);
164            let chunks = vec![
165                Ok(Chunk::text_delta(self.reply.clone())),
166                Ok(Chunk::Stop(StopReason::EndTurn)),
167            ];
168            Ok(stream::iter(chunks).boxed())
169        }
170    }
171
172    fn sample_transcript() -> Vec<ParticipationMsg> {
173        vec![
174            ParticipationMsg {
175                speaker: "alice".to_owned(),
176                text: "can someone deploy the build?".to_owned(),
177                is_self: false,
178            },
179            ParticipationMsg {
180                speaker: "bot".to_owned(),
181                text: "on it".to_owned(),
182                is_self: true,
183            },
184        ]
185    }
186
187    #[tokio::test]
188    async fn respond_reply_maps_to_respond() {
189        let provider = MockProvider::new("respond");
190        let verdict = classify_participation(&provider, "fast", "bot", &sample_transcript())
191            .await
192            .expect("classify");
193        assert_eq!(verdict, Verdict::Respond);
194    }
195
196    #[tokio::test]
197    async fn notify_reply_is_case_insensitive() {
198        let provider = MockProvider::new("NOTIFY please");
199        let verdict = classify_participation(&provider, "fast", "bot", &sample_transcript())
200            .await
201            .expect("classify");
202        assert_eq!(verdict, Verdict::Notify);
203    }
204
205    #[tokio::test]
206    async fn ignore_reply_maps_to_ignore() {
207        let provider = MockProvider::new("ignore");
208        let verdict = classify_participation(&provider, "fast", "bot", &sample_transcript())
209            .await
210            .expect("classify");
211        assert_eq!(verdict, Verdict::Ignore);
212    }
213
214    #[tokio::test]
215    async fn garbage_reply_defaults_to_ignore() {
216        let provider = MockProvider::new("\u{af}\\_(\u{30c4})_/\u{af} no idea");
217        let verdict = classify_participation(&provider, "fast", "bot", &sample_transcript())
218            .await
219            .expect("classify");
220        // Default-to-silence is the key invariant.
221        assert_eq!(verdict, Verdict::Ignore);
222    }
223
224    #[tokio::test]
225    async fn empty_reply_defaults_to_ignore() {
226        let provider = MockProvider::new("");
227        let verdict = classify_participation(&provider, "fast", "bot", &sample_transcript())
228            .await
229            .expect("classify");
230        // Default-to-silence is the key invariant.
231        assert_eq!(verdict, Verdict::Ignore);
232    }
233
234    #[tokio::test]
235    async fn request_carries_transcript_text() {
236        let provider = MockProvider::new("ignore");
237        let _ = classify_participation(&provider, "fast", "bot", &sample_transcript())
238            .await
239            .expect("classify");
240
241        let req = provider.captured.lock().unwrap().clone().expect("captured");
242        // A system message with triage instructions, then the transcript.
243        assert_eq!(req.messages.len(), 2);
244        assert_eq!(req.messages[0].role, Role::System);
245        assert_eq!(req.messages[1].role, Role::User);
246
247        let user_text = match &req.messages[1].content[0] {
248            Content::Text(t) => t.clone(),
249            other => panic!("expected text content, got {other:?}"),
250        };
251        assert!(user_text.contains("can someone deploy the build?"));
252        // The agent's own line is labelled as itself, not the raw speaker.
253        assert!(user_text.contains("bot: on it"));
254
255        let sys_text = match &req.messages[0].content[0] {
256            Content::Text(t) => t.clone(),
257            other => panic!("expected text content, got {other:?}"),
258        };
259        assert!(sys_text.contains("bot"));
260        assert!(sys_text.to_lowercase().contains("ignore"));
261    }
262}