monoloop_loop/transaction/
acp_encoder.rs1use 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#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
16pub enum AcpPromptWireShape {
17 #[default]
19 ParamsObject,
20 MethodEnvelope,
22 PlainText,
24}
25
26#[derive(Clone, Debug)]
28pub struct AcpPromptEncoder {
29 pub shape: AcpPromptWireShape,
31 pub dialect: DialectDescriptor,
33 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 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 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 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 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 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 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(¶ms).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 Err(EncodingError::Unsupported(
194 "ACP external agents do not encode model tool continuations",
195 ))
196 }
197}
198
199fn 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#[derive(Clone, Debug)]
228pub struct HeadlessPromptEncoder {
229 pub dialect: DialectDescriptor,
231 pub max_encoded_bytes: usize,
233}
234
235impl HeadlessPromptEncoder {
236 pub fn zai() -> Self {
238 Self {
239 dialect: DialectDescriptor::zai_cli("1"),
240 max_encoded_bytes: 1024 * 1024,
241 }
242 }
243
244 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 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}