Skip to main content

forge_workspace/mcp/
voice.rs

1//! Voice MCP - speak one utterance to the person listening to this seat
2//! (`mcp__forge__voice__say`).
3//!
4//! The seventh toggleable family, so a lead can withhold it from a worker
5//! that should not speak. This tool is the only way the agent's words become
6//! speech: the chat it writes is never spoken.
7
8use std::sync::Arc;
9
10use forge_sdk::mcp::server::McpServerBuilder;
11use forge_sdk::mcp::tool::{Tool, ToolInput, ToolOutput};
12
13use crate::SessionSlot;
14use crate::voice::{SayError, VoiceService};
15
16/// Attach `voice__say` to an existing [`McpServerBuilder`].
17pub(crate) fn add_tools(
18    builder: McpServerBuilder,
19    voice: Arc<VoiceService>,
20    slot: SessionSlot,
21) -> McpServerBuilder {
22    builder.tool(Say { voice, slot })
23}
24
25struct Say {
26    voice: Arc<VoiceService>,
27    slot: SessionSlot,
28}
29
30#[derive(serde::Deserialize)]
31#[serde(deny_unknown_fields)]
32struct Args {
33    text: String,
34}
35
36#[async_trait::async_trait]
37impl Tool for Say {
38    fn name(&self) -> &'static str {
39        "voice__say"
40    }
41
42    fn description(&self) -> &'static str {
43        "Speak one utterance to the person listening to this seat. While a listener is present, \
44         call this with the substance of each user-facing message, phrased for the ear rather \
45         than read from the page. Use it for anything they should hear; the text you write in \
46         the chat is not spoken. Returns `spoken: queued` when the first audio renders, or `no \
47         listener` when no client has voice on for this seat."
48    }
49
50    fn input_schema(&self) -> serde_json::Value {
51        serde_json::json!({
52            "type": "object",
53            "properties": {
54                "text": {
55                    "type": "string",
56                    "description": "One utterance, typically a sentence or a short paragraph.",
57                },
58            },
59            "required": ["text"],
60            "additionalProperties": false,
61        })
62    }
63
64    async fn call(&self, input: ToolInput) -> ToolOutput {
65        let Args { text } = match serde_json::from_value(input.value) {
66            Ok(args) => args,
67            Err(error) => return ToolOutput::error(format!("invalid arguments: {error}")),
68        };
69        if text.trim().is_empty() {
70            return ToolOutput::error("nothing to say: the utterance is empty");
71        }
72        match self.voice.say(&self.slot, text).await {
73            Ok(()) => ToolOutput::text("spoken: queued"),
74            Err(SayError::NoListener) => ToolOutput::text("no listener"),
75            Err(error) => ToolOutput::error(error.to_string()),
76        }
77    }
78}
79
80#[cfg(test)]
81mod tests {
82    use std::sync::Arc;
83
84    use forge_primitives::SessionSlot;
85    use forge_sdk::mcp::server::McpServerBuilder;
86    use forge_sdk::mcp::tool::{Tool, ToolInput, ToolOutput, ToolOutputBlock};
87    use forge_tts::Chunk;
88
89    use super::{Say, add_tools};
90    use crate::voice::{AudioSink, NoteDispatch, VoiceService, VoiceSettings};
91
92    fn slot() -> SessionSlot {
93        SessionSlot::new("Org", "project", "lead")
94    }
95
96    struct SilentSink;
97    impl AudioSink for SilentSink {
98        fn push(&self, _seat: &SessionSlot, _chunk: Chunk) {}
99    }
100
101    struct SilentNotes;
102    impl NoteDispatch for SilentNotes {
103        fn note(&self, _seat: &SessionSlot, _text: &str) {}
104    }
105
106    fn service() -> Arc<VoiceService> {
107        let loader = Arc::new(|| Err("no model loads in this test".to_owned()));
108        Arc::new(VoiceService::new(
109            loader,
110            VoiceSettings::default(),
111            Arc::new(SilentSink) as Arc<dyn AudioSink>,
112            Arc::new(SilentNotes) as Arc<dyn NoteDispatch>,
113        ))
114    }
115
116    async fn say(text: &str) -> ToolOutput {
117        Say { voice: service(), slot: slot() }
118            .call(ToolInput { value: serde_json::json!({ "text": text }) })
119            .await
120    }
121
122    #[tokio::test]
123    async fn a_say_with_no_listener_answers_no_listener() {
124        let output = say("Hello.").await;
125        assert!(!output.is_error, "no listener is an answer, not a failure: {output:?}");
126        let ToolOutputBlock::Text { text } = &output.blocks[0] else {
127            panic!("the answer is a line of text");
128        };
129        assert_eq!(text, "no listener");
130    }
131
132    #[tokio::test]
133    async fn an_empty_text_is_refused() {
134        let output = say("   ").await;
135        assert!(output.is_error, "an empty utterance is refused");
136        let ToolOutputBlock::Text { text } = &output.blocks[0] else {
137            panic!("the refusal is a line of text");
138        };
139        assert_eq!(text, "nothing to say: the utterance is empty");
140    }
141
142    #[test]
143    fn the_family_registers_one_tool_named_voice_say() {
144        let server = add_tools(McpServerBuilder::new("forge", "0.0.0"), service(), slot()).build();
145        assert_eq!(server.tool_names(), vec!["voice__say".to_owned()]);
146    }
147
148    #[test]
149    fn the_description_carries_the_conversational_rule() {
150        let description = Say { voice: service(), slot: slot() }.description();
151        assert!(
152            description.contains(
153                "While a listener is present, call this with the substance of each user-facing \
154                 message, phrased for the ear rather than read from the page."
155            ),
156            "the tool description carries the voice-mode rule: {description}",
157        );
158    }
159}