polyc-agent 2026.7.0

The agent turn loop: provider + tool-call routing, shared by the control plane and harness.
Documentation
//! The provider-agnostic "should the agent speak?" classifier.
//!
//! A multi-party thread (a group chat channel, a group DM) is noisy: most messages
//! are not for the agent, and a bot that replies to everything is worse than
//! one that stays quiet. This module is the cheap triage gate that runs
//! *before* the expensive agent turn: given the recent transcript, it asks a
//! model for a one-word verdict — [`Verdict::Respond`], [`Verdict::Notify`],
//! or [`Verdict::Ignore`] — and **defaults to silence** for anything it cannot
//! confidently classify.
//!
//! It is deliberately provider-agnostic ([`classify_participation`] takes a
//! generic `P: LlmProvider`) and lives in the agent crate, not any single edge
//! adapter: the control plane hosts it over RPC, and adapters stay free of
//! model/LLM logic.

use polyc_llm::{CompletionRequest, Content, LlmProvider, Message, Role, turn::collect_turn};

/// The triage outcome for the latest message in a thread.
///
/// Ordered from most to least engagement. [`Ignore`](Verdict::Ignore) is the
/// safe default: the classifier returns it for anything it does not recognise
/// as a clear `respond` or `notify`.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Verdict {
    /// The agent should reply in-thread — it was directly addressed or is
    /// clearly the best party to help.
    Respond,
    /// Worth flagging to an operator, but the agent should not reply.
    Notify,
    /// Stay silent. The safe default.
    Ignore,
}

/// One line of a multi-party thread, as seen by the classifier.
#[derive(Debug, Clone)]
pub struct ParticipationMsg {
    /// Display name of whoever wrote the line.
    pub speaker: String,
    /// The message text.
    pub text: String,
    /// `true` when this line is one of the agent's own past messages.
    pub is_self: bool,
}

/// System-prompt template for the triage gate. `{bot_name}` is the agent's name.
fn system_prompt(bot_name: &str) -> String {
    format!(
        "You are {bot_name}, a participant in a multi-party Slack thread. Classify whether to \
         engage with the LATEST message as exactly one of: respond, notify, ignore. Default to \
         ignore. Choose respond only if you are directly addressed or are clearly the best party \
         to help. Choose notify if the message is worth flagging to an operator but warrants no \
         reply. If another human is already handling it, ignore. Answer with a single word: \
         respond, notify, or ignore."
    )
}

/// Render the transcript as `<speaker>: <text>`, one line per message, with the
/// agent's own lines labelled as itself so the model knows which turns are its.
fn render_transcript(bot_name: &str, transcript: &[ParticipationMsg]) -> String {
    let mut out = String::new();
    for msg in transcript {
        let speaker = if msg.is_self { bot_name } else { &msg.speaker };
        out.push_str(speaker);
        out.push_str(": ");
        out.push_str(&msg.text);
        out.push('\n');
    }
    out
}

/// Parse a model reply into a [`Verdict`], case-insensitively.
///
/// `respond` wins over `notify` if both appear; anything unrecognised (and the
/// empty string) falls through to [`Verdict::Ignore`] — silence is the safe
/// default.
fn parse_verdict(text: &str) -> Verdict {
    let lower = text.to_lowercase();
    if lower.contains("respond") {
        Verdict::Respond
    } else if lower.contains("notify") {
        Verdict::Notify
    } else {
        Verdict::Ignore
    }
}

/// Classify whether the agent should engage with the latest message in
/// `transcript`.
///
/// Builds a [`CompletionRequest`] for `model` carrying a system message with
/// the triage instructions and a user message with the rendered transcript,
/// runs it through `provider`, folds the stream with [`collect_turn`], and
/// parses the model's one-word reply. Returns [`Verdict::Ignore`] for any reply
/// it cannot confidently read as `respond` or `notify`.
///
/// This is a *cheap triage gate* meant to run before the full agent turn; keep
/// `model` pointed at a fast, inexpensive backend. An empty `model` defers
/// to the provider's configured default; a non-empty value overrides it
/// per-request (it reaches the backend as a model id — never pass a label).
///
/// # Errors
///
/// Propagates `P::Error` from [`LlmProvider::complete`] (pre-stream failures)
/// and from [`collect_turn`] (mid-stream faults).
pub async fn classify_participation<P: LlmProvider + ?Sized>(
    provider: &P,
    model: &str,
    bot_name: &str,
    transcript: &[ParticipationMsg],
) -> Result<Verdict, P::Error> {
    let mut req = CompletionRequest::new(model);
    req.messages.push(Message {
        role: Role::System,
        content: vec![Content::Text(system_prompt(bot_name))],
    });
    req.messages.push(Message {
        role: Role::User,
        content: vec![Content::Text(render_transcript(bot_name, transcript))],
    });

    let stream = provider.complete(req).await?;
    let out = collect_turn(stream).await?;
    Ok(parse_verdict(&out.text))
}

#[cfg(test)]
mod tests {
    #![allow(clippy::pedantic, clippy::nursery, missing_docs)]

    use std::sync::{Arc, Mutex};

    use async_trait::async_trait;
    use futures::stream::{self, BoxStream, StreamExt};
    use polyc_llm::{Chunk, StopReason, error::DummyError};

    use super::*;

    /// In-test provider that returns a configurable canned reply and captures
    /// the request it was handed (so tests can assert on what was built).
    #[derive(Clone)]
    struct MockProvider {
        reply: String,
        captured: Arc<Mutex<Option<CompletionRequest>>>,
    }

    impl MockProvider {
        fn new(reply: &str) -> Self {
            Self {
                reply: reply.to_owned(),
                captured: Arc::new(Mutex::new(None)),
            }
        }
    }

    #[async_trait]
    impl LlmProvider for MockProvider {
        type Error = DummyError;

        async fn complete(
            &self,
            req: CompletionRequest,
        ) -> Result<BoxStream<'static, Result<Chunk, Self::Error>>, Self::Error> {
            *self.captured.lock().unwrap() = Some(req);
            let chunks = vec![
                Ok(Chunk::text_delta(self.reply.clone())),
                Ok(Chunk::Stop(StopReason::EndTurn)),
            ];
            Ok(stream::iter(chunks).boxed())
        }
    }

    fn sample_transcript() -> Vec<ParticipationMsg> {
        vec![
            ParticipationMsg {
                speaker: "alice".to_owned(),
                text: "can someone deploy the build?".to_owned(),
                is_self: false,
            },
            ParticipationMsg {
                speaker: "bot".to_owned(),
                text: "on it".to_owned(),
                is_self: true,
            },
        ]
    }

    #[tokio::test]
    async fn respond_reply_maps_to_respond() {
        let provider = MockProvider::new("respond");
        let verdict = classify_participation(&provider, "fast", "bot", &sample_transcript())
            .await
            .expect("classify");
        assert_eq!(verdict, Verdict::Respond);
    }

    #[tokio::test]
    async fn notify_reply_is_case_insensitive() {
        let provider = MockProvider::new("NOTIFY please");
        let verdict = classify_participation(&provider, "fast", "bot", &sample_transcript())
            .await
            .expect("classify");
        assert_eq!(verdict, Verdict::Notify);
    }

    #[tokio::test]
    async fn ignore_reply_maps_to_ignore() {
        let provider = MockProvider::new("ignore");
        let verdict = classify_participation(&provider, "fast", "bot", &sample_transcript())
            .await
            .expect("classify");
        assert_eq!(verdict, Verdict::Ignore);
    }

    #[tokio::test]
    async fn garbage_reply_defaults_to_ignore() {
        let provider = MockProvider::new("\u{af}\\_(\u{30c4})_/\u{af} no idea");
        let verdict = classify_participation(&provider, "fast", "bot", &sample_transcript())
            .await
            .expect("classify");
        // Default-to-silence is the key invariant.
        assert_eq!(verdict, Verdict::Ignore);
    }

    #[tokio::test]
    async fn empty_reply_defaults_to_ignore() {
        let provider = MockProvider::new("");
        let verdict = classify_participation(&provider, "fast", "bot", &sample_transcript())
            .await
            .expect("classify");
        // Default-to-silence is the key invariant.
        assert_eq!(verdict, Verdict::Ignore);
    }

    #[tokio::test]
    async fn request_carries_transcript_text() {
        let provider = MockProvider::new("ignore");
        let _ = classify_participation(&provider, "fast", "bot", &sample_transcript())
            .await
            .expect("classify");

        let req = provider.captured.lock().unwrap().clone().expect("captured");
        // A system message with triage instructions, then the transcript.
        assert_eq!(req.messages.len(), 2);
        assert_eq!(req.messages[0].role, Role::System);
        assert_eq!(req.messages[1].role, Role::User);

        let user_text = match &req.messages[1].content[0] {
            Content::Text(t) => t.clone(),
            other => panic!("expected text content, got {other:?}"),
        };
        assert!(user_text.contains("can someone deploy the build?"));
        // The agent's own line is labelled as itself, not the raw speaker.
        assert!(user_text.contains("bot: on it"));

        let sys_text = match &req.messages[0].content[0] {
            Content::Text(t) => t.clone(),
            other => panic!("expected text content, got {other:?}"),
        };
        assert!(sys_text.contains("bot"));
        assert!(sys_text.to_lowercase().contains("ignore"));
    }
}