Skip to main content

monoloop_loop/transaction/
acp_encoder.rs

1//! Outbound encoder for ACP external-agent Channels (`session/prompt` params).
2//!
3//! Emits provider-neutral JSON params (or full method envelope). Connector only
4//! transports bytes — no prompt on process argv.
5
6use monoloop_contracts::{
7    Bytes, CanonicalMessage, DialectDescriptor, EncodedExchange, EncodingError,
8    ExchangeInputPolicy, ExtensionKey, InitialEncodeRequest, OutboundDialectEncoder,
9    ToolContinuationEncodeRequest, VersionedExtension,
10};
11use serde_json::{json, Map, Value};
12use std::collections::BTreeMap;
13
14/// How ACP prompt bytes are shaped for the Connector input path.
15#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
16pub enum AcpPromptWireShape {
17    /// JSON object `{ "prompt": [ { "type":"text", "text": ... } ] }` (Grok bridge).
18    #[default]
19    ParamsObject,
20    /// Full JSON-RPC request without id: `{ "method":"session/prompt", "params": ... }`.
21    MethodEnvelope,
22    /// Plain UTF-8 user text only (Cursor/Codex/Agy prompt_text bridges).
23    PlainText,
24}
25
26/// ACP session/prompt encoder for external-agent Channels.
27#[derive(Clone, Debug)]
28pub struct AcpPromptEncoder {
29    /// Wire shape.
30    pub shape: AcpPromptWireShape,
31    /// Dialect stamp on encoded exchange.
32    pub dialect: DialectDescriptor,
33    /// Max encoded body bytes.
34    pub max_encoded_bytes: usize,
35}
36
37impl Default for AcpPromptEncoder {
38    fn default() -> Self {
39        Self {
40            shape: AcpPromptWireShape::ParamsObject,
41            dialect: DialectDescriptor::acp_json_rpc("1"),
42            max_encoded_bytes: 1024 * 1024,
43        }
44    }
45}
46
47impl AcpPromptEncoder {
48    /// Grok Build wire shape (params object for session/prompt).
49    pub fn grok() -> Self {
50        Self {
51            shape: AcpPromptWireShape::ParamsObject,
52            dialect: DialectDescriptor::acp_json_rpc("1"),
53            max_encoded_bytes: 1024 * 1024,
54        }
55    }
56
57    /// Cursor ACP (plain text → `session/prompt` in connector bridge).
58    pub fn cursor() -> Self {
59        Self {
60            shape: AcpPromptWireShape::PlainText,
61            dialect: DialectDescriptor::cursor_acp("1"),
62            max_encoded_bytes: 1024 * 1024,
63        }
64    }
65
66    /// Codex ACP plain text.
67    pub fn codex() -> Self {
68        Self {
69            shape: AcpPromptWireShape::PlainText,
70            dialect: DialectDescriptor::codex_acp("1"),
71            max_encoded_bytes: 1024 * 1024,
72        }
73    }
74
75    /// Antigravity / agy plain text.
76    pub fn agy() -> Self {
77        Self {
78            shape: AcpPromptWireShape::PlainText,
79            dialect: DialectDescriptor::agy_acp("1"),
80            max_encoded_bytes: 1024 * 1024,
81        }
82    }
83
84    fn collect_user_text(messages: &[CanonicalMessage]) -> Result<String, EncodingError> {
85        let mut text = String::new();
86        for msg in messages {
87            match msg {
88                CanonicalMessage::User { content, .. }
89                | CanonicalMessage::System { content, .. } => {
90                    for part in content {
91                        if !text.is_empty() {
92                            text.push('\n');
93                        }
94                        text.push_str(part.text());
95                    }
96                }
97                CanonicalMessage::Assistant { content, .. } => {
98                    for part in content {
99                        if !text.is_empty() {
100                            text.push('\n');
101                        }
102                        text.push_str(part.text());
103                    }
104                }
105                CanonicalMessage::Tool { content, .. } => {
106                    for part in content {
107                        if !text.is_empty() {
108                            text.push('\n');
109                        }
110                        text.push_str(part.text());
111                    }
112                }
113            }
114        }
115        if text.trim().is_empty() {
116            return Err(EncodingError::UnrepresentableInput);
117        }
118        Ok(text)
119    }
120}
121
122impl OutboundDialectEncoder for AcpPromptEncoder {
123    fn encode_initial(
124        &self,
125        request: InitialEncodeRequest<'_>,
126    ) -> Result<EncodedExchange, EncodingError> {
127        let text = Self::collect_user_text(request.input.messages())?;
128        // Tools for external agents must go through MCP, not model tool arrays.
129        if !request.tools.is_empty() {
130            return Err(EncodingError::Unsupported(
131                "non-empty tools require MCP gateway for external-agent ACP profiles",
132            ));
133        }
134        // D-023: admitted extensions must be encoded or rejected — never dropped.
135        let meta = encode_acp_extension_meta(&request.config.extensions)?;
136        let bytes = match self.shape {
137            AcpPromptWireShape::PlainText => {
138                if meta.is_some() {
139                    return Err(EncodingError::Unsupported(
140                        "plain-text ACP shape cannot encode extensions",
141                    ));
142                }
143                Bytes::from(text.into_bytes())
144            }
145            AcpPromptWireShape::ParamsObject => {
146                let mut params = json!({
147                    "prompt": [{ "type": "text", "text": text }]
148                });
149                if let Some(m) = meta {
150                    params
151                        .as_object_mut()
152                        .ok_or(EncodingError::UnrepresentableInput)?
153                        .insert("_meta".into(), m);
154                }
155                Bytes::from(
156                    serde_json::to_vec(&params).map_err(|_| EncodingError::UnrepresentableInput)?,
157                )
158            }
159            AcpPromptWireShape::MethodEnvelope => {
160                let mut params = json!({
161                    "prompt": [{ "type": "text", "text": text }]
162                });
163                if let Some(m) = meta {
164                    params
165                        .as_object_mut()
166                        .ok_or(EncodingError::UnrepresentableInput)?
167                        .insert("_meta".into(), m);
168                }
169                let v = json!({
170                    "method": "session/prompt",
171                    "params": params
172                });
173                Bytes::from(
174                    serde_json::to_vec(&v).map_err(|_| EncodingError::UnrepresentableInput)?,
175                )
176            }
177        };
178        if bytes.len() > self.max_encoded_bytes {
179            return Err(EncodingError::LimitExceeded);
180        }
181        Ok(EncodedExchange {
182            bytes,
183            required_input_dialect: self.dialect.clone(),
184            input_policy: ExchangeInputPolicy::SendAndFinish,
185        })
186    }
187
188    fn encode_tool_continuation(
189        &self,
190        _request: ToolContinuationEncodeRequest<'_>,
191    ) -> Result<EncodedExchange, EncodingError> {
192        // External-agent tool results return via MCP, not model-tool continuation.
193        Err(EncodingError::Unsupported(
194            "ACP external agents do not encode model tool continuations",
195        ))
196    }
197}
198
199/// Encode admitted ACP / x.ai extensions into `_meta` (D-023).
200///
201/// Returns `None` when empty. Unknown namespaces fail closed.
202fn encode_acp_extension_meta(
203    extensions: &BTreeMap<ExtensionKey, VersionedExtension>,
204) -> Result<Option<Value>, EncodingError> {
205    if extensions.is_empty() {
206        return Ok(None);
207    }
208    let mut meta = Map::new();
209    for (key, ext) in extensions {
210        let field = key
211            .as_str()
212            .strip_prefix("acp.meta.")
213            .or_else(|| key.as_str().strip_prefix("x.ai."))
214            .ok_or(EncodingError::Unsupported("non-acp extension"))?;
215        if meta.contains_key(field) {
216            return Err(EncodingError::Unsupported("duplicate extension meta key"));
217        }
218        meta.insert(field.to_string(), ext.value.clone());
219    }
220    Ok(Some(Value::Object(meta)))
221}
222
223/// Headless CLI encoder: plain text prompt body for Z.ai / Claude print mode.
224///
225/// Prompt on argv is a documented profile exception (CLI product contract);
226/// the encoder still owns the text content (not secrets).
227#[derive(Clone, Debug)]
228pub struct HeadlessPromptEncoder {
229    /// Dialect stamp.
230    pub dialect: DialectDescriptor,
231    /// Max bytes.
232    pub max_encoded_bytes: usize,
233}
234
235impl HeadlessPromptEncoder {
236    /// Z.ai CLI.
237    pub fn zai() -> Self {
238        Self {
239            dialect: DialectDescriptor::zai_cli("1"),
240            max_encoded_bytes: 1024 * 1024,
241        }
242    }
243
244    /// Claude Code print mode.
245    pub fn claude() -> Self {
246        Self {
247            dialect: DialectDescriptor::claude_code("1"),
248            max_encoded_bytes: 1024 * 1024,
249        }
250    }
251}
252
253impl OutboundDialectEncoder for HeadlessPromptEncoder {
254    fn encode_initial(
255        &self,
256        request: InitialEncodeRequest<'_>,
257    ) -> Result<EncodedExchange, EncodingError> {
258        if !request.tools.is_empty() {
259            return Err(EncodingError::Unsupported(
260                "headless CLI profiles reject Monoloop-linked tools (MCP None)",
261            ));
262        }
263        // D-023: plain argv/text body cannot carry extensions — fail closed.
264        if !request.config.extensions.is_empty() {
265            return Err(EncodingError::Unsupported(
266                "headless CLI encoder cannot encode extensions",
267            ));
268        }
269        let text = AcpPromptEncoder::collect_user_text(request.input.messages())?;
270        let bytes = Bytes::from(text.into_bytes());
271        if bytes.len() > self.max_encoded_bytes {
272            return Err(EncodingError::LimitExceeded);
273        }
274        Ok(EncodedExchange {
275            bytes,
276            required_input_dialect: self.dialect.clone(),
277            input_policy: ExchangeInputPolicy::SendAndFinish,
278        })
279    }
280
281    fn encode_tool_continuation(
282        &self,
283        _request: ToolContinuationEncodeRequest<'_>,
284    ) -> Result<EncodedExchange, EncodingError> {
285        Err(EncodingError::Unsupported(
286            "headless CLI has no tool continuation encoding",
287        ))
288    }
289}
290
291#[cfg(test)]
292mod tests {
293    use super::*;
294    use monoloop_contracts::{user_text_input, EffectiveConfig, ExchangeId, TransactionId};
295
296    fn bare_cfg() -> EffectiveConfig {
297        EffectiveConfig {
298            model: None,
299            temperature: None,
300            reasoning_effort: None,
301            max_output_tokens: None,
302            stop: vec![],
303            response_format: None,
304            continuation_policy: Default::default(),
305            deadline: None,
306            extensions: Default::default(),
307            session: Default::default(),
308        }
309    }
310
311    #[test]
312    fn grok_params_object_has_no_prompt_on_argv_shape() {
313        let enc = AcpPromptEncoder::grok();
314        let input = user_text_input("hello agent").unwrap();
315        let tid = TransactionId::generate();
316        let eid = ExchangeId::generate();
317        let encoded = enc
318            .encode_initial(InitialEncodeRequest {
319                transaction_id: &tid,
320                exchange_id: &eid,
321                input: &input,
322                config: &bare_cfg(),
323                tools: &[],
324            })
325            .unwrap();
326        let v: serde_json::Value = serde_json::from_slice(&encoded.bytes).unwrap();
327        assert!(v.get("prompt").is_some());
328        assert!(v.get("method").is_none());
329    }
330
331    #[test]
332    fn plain_text_cursor_shape() {
333        let enc = AcpPromptEncoder::cursor();
334        let input = user_text_input("hi").unwrap();
335        let tid = TransactionId::generate();
336        let eid = ExchangeId::generate();
337        let encoded = enc
338            .encode_initial(InitialEncodeRequest {
339                transaction_id: &tid,
340                exchange_id: &eid,
341                input: &input,
342                config: &bare_cfg(),
343                tools: &[],
344            })
345            .unwrap();
346        assert_eq!(&encoded.bytes[..], b"hi");
347    }
348}