Skip to main content

liminal_cli/mcp/
held.rs

1//! The ticket carries a held answer without interpreting its words.
2
3use std::num::NonZeroUsize;
4
5use serde_json::{Value, json};
6
7use super::tools::ToolResult;
8
9/// A held result whose text is never included in diagnostics.
10pub struct HeldWords<'a> {
11    draft: &'a str,
12    words: &'a str,
13}
14
15impl std::fmt::Debug for HeldWords<'_> {
16    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
17        formatter.debug_struct("HeldWords").finish_non_exhaustive()
18    }
19}
20
21/// A malformed held result; the rejected bytes are never retained here.
22#[derive(Debug, thiserror::Error)]
23#[error("held_result_invalid")]
24pub struct HeldResultInvalid;
25
26impl<'a> HeldWords<'a> {
27    /// Checks the server's held answer before carrying it to the harness.
28    /// `identifier_bytes` bounds routing metadata; `words_bytes` bounds one
29    /// tool response without truncating what the approver saw.
30    ///
31    /// # Errors
32    /// Refuses an absent or controlled id, empty words, or either byte bound.
33    pub fn new(
34        draft: &'a str,
35        words: &'a str,
36        identifier_bytes: NonZeroUsize,
37        words_bytes: NonZeroUsize,
38    ) -> Result<Self, HeldResultInvalid> {
39        if draft.is_empty()
40            || draft.len() > identifier_bytes.get()
41            || draft.chars().any(char::is_control)
42            || words.is_empty()
43            || words.len() > words_bytes.get()
44        {
45            return Err(HeldResultInvalid);
46        }
47        Ok(Self { draft, words })
48    }
49
50    /// The successful structured answer; authority remains with the server.
51    #[must_use]
52    pub fn tool_result(&self) -> ToolResult {
53        ToolResult::ok(json!({"draft":self.draft, "words":self.words}))
54    }
55}
56
57/// Supplies the text of an already successful held result to MCP shaping.
58/// A failed result stays a failure, even if it also carries a draft id.
59#[must_use]
60pub fn text(result: &ToolResult) -> Option<String> {
61    if result.is_error {
62        return None;
63    }
64    let object = result.structured.as_object()?;
65    if object.len() != 2 {
66        return None;
67    }
68    let draft = object.get("draft")?.as_str()?;
69    let words = object.get("words")?.as_str()?;
70    if draft.is_empty() || draft.chars().any(char::is_control) || words.is_empty() {
71        return None;
72    }
73    Some(format!("held by draft {draft}: {words}"))
74}
75
76/// Recognises a wire answer without converting an ordinary refusal to held.
77///
78/// # Errors
79/// A held answer carries malformed or oversized fields.
80pub fn from_wire(
81    answer: &Value,
82    identifier_bytes: NonZeroUsize,
83    words_bytes: NonZeroUsize,
84) -> Result<Option<ToolResult>, HeldResultInvalid> {
85    let paired = answer.as_object().is_some_and(|object| {
86        object.len() == 2 && object.contains_key("draft") && object.contains_key("words")
87    });
88    if answer.get("state").and_then(Value::as_str) != Some("held") && !paired {
89        return Ok(None);
90    }
91    if answer.get("refusal").is_some()
92        || answer.get("error").is_some()
93        || answer
94            .get("isError")
95            .is_some_and(|value| value.as_bool() != Some(false))
96        || answer
97            .get("is_error")
98            .is_some_and(|value| value.as_bool() != Some(false))
99    {
100        return Err(HeldResultInvalid);
101    }
102    let draft = answer
103        .get("draft")
104        .and_then(Value::as_str)
105        .ok_or(HeldResultInvalid)?;
106    let words = answer
107        .get("words")
108        .and_then(Value::as_str)
109        .ok_or(HeldResultInvalid)?;
110    Ok(Some(
111        HeldWords::new(draft, words, identifier_bytes, words_bytes)?.tool_result(),
112    ))
113}
114
115impl std::fmt::Debug for ToolResult {
116    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
117        formatter
118            .debug_struct("ToolResult")
119            .field("is_error", &self.is_error)
120            .finish_non_exhaustive()
121    }
122}
123
124#[cfg(test)]
125#[path = "held_tests.rs"]
126mod tests;