Skip to main content

leviath_agent_client/
mapping.rs

1//! Pure translations between Leviath's own types and the Agent Client Protocol.
2//!
3//! Everything here is a total function over plain data - no I/O, no daemon, no
4//! async - so the stdio server in `leviath-cli` is left with only sequencing to
5//! do, and every mapping decision is unit-testable in isolation.
6
7use leviath_core::interaction::{InteractionKind, InteractionRequest};
8use leviath_core::run_meta::RunStatus;
9
10use crate::protocol::{
11    ContentBlock, PermissionOption, PermissionOptionKind, RequestPermissionParams, StopReason,
12    ToolCallRef, ToolCallStatus, ToolKind,
13};
14
15/// The option id returned when the user approves a single tool call.
16pub const OPTION_ALLOW_ONCE: &str = "allow-once";
17/// The option id returned when the user approves this tool for the whole session.
18pub const OPTION_ALLOW_ALWAYS: &str = "allow-always";
19/// The option id returned when the user rejects a tool call.
20pub const OPTION_REJECT_ONCE: &str = "reject-once";
21
22/// Flatten a prompt's content blocks into the single task/message string Leviath
23/// agents consume.
24///
25/// `text` blocks contribute their text. `resource` blocks (the `embeddedContext`
26/// capability) contribute their inlined text under a `--- <uri> ---` header, so
27/// the model can tell attached context from the instruction itself. Every other
28/// block kind - `image`, `audio`, `resource_link` - is dropped: we advertise no
29/// support for them, and silently ignoring one block is far better than failing
30/// the whole prompt.
31///
32/// Blocks are joined with a blank line and the result is trimmed, so a prompt of
33/// only unsupported blocks yields `""` (which the caller treats as an error
34/// rather than spawning an agent with an empty task).
35pub fn flatten_prompt(blocks: &[ContentBlock]) -> String {
36    let mut parts: Vec<String> = Vec::new();
37    for block in blocks {
38        match block.kind.as_str() {
39            "text" => {
40                if let Some(text) = block
41                    .text
42                    .as_deref()
43                    .map(str::trim)
44                    .filter(|t| !t.is_empty())
45                {
46                    parts.push(text.to_string());
47                }
48            }
49            "resource" => {
50                if let Some(resource) = &block.resource
51                    && let Some(text) = resource.text.as_deref()
52                {
53                    parts.push(format!("--- {} ---\n{}", resource.uri, text));
54                }
55            }
56            _ => {}
57        }
58    }
59    parts.join("\n\n").trim().to_string()
60}
61
62/// Parse `---region:<name>---` markers out of a flattened prompt into a
63/// name→content map.
64///
65/// A line that is exactly `---region:<name>---` (after trimming) opens a region
66/// block; its content runs until the next `---region:...---` marker, an
67/// `---end-regions---` line, or the end of the text. Any text before the first
68/// marker becomes the `task` region. With **no** markers at all, the whole text
69/// is returned as `{ "task": text }` - the exact pre-feature behavior, so hosts
70/// that don't use markers are unaffected.
71///
72/// Region bodies are trimmed; empty blocks are dropped. Pure - no I/O.
73pub fn parse_region_markers(text: &str) -> std::collections::HashMap<String, String> {
74    use std::collections::HashMap;
75
76    let marker_name = |line: &str| -> Option<String> {
77        let t = line.trim();
78        t.strip_prefix("---region:")
79            .and_then(|rest| rest.strip_suffix("---"))
80            .map(|n| n.trim().to_string())
81    };
82
83    let mut out = HashMap::new();
84    // Current region name (None = the leading "task" block) and its accumulated
85    // lines. `ended` becomes true after `---end-regions---`.
86    let mut current: Option<String> = None;
87    let mut buf: Vec<&str> = Vec::new();
88    let mut ended = false;
89    let mut saw_marker = false;
90
91    let flush = |name: &Option<String>, buf: &mut Vec<&str>, out: &mut HashMap<String, String>| {
92        let body = buf.join("\n");
93        let body = body.trim();
94        if !body.is_empty() {
95            let key = name.clone().unwrap_or_else(|| "task".to_string());
96            out.insert(key, body.to_string());
97        }
98        buf.clear();
99    };
100
101    for line in text.lines() {
102        if ended {
103            break;
104        }
105        if line.trim() == "---end-regions---" {
106            flush(&current, &mut buf, &mut out);
107            ended = true;
108            continue;
109        }
110        if let Some(name) = marker_name(line) {
111            flush(&current, &mut buf, &mut out);
112            current = Some(name);
113            saw_marker = true;
114            continue;
115        }
116        buf.push(line);
117    }
118    if !ended {
119        flush(&current, &mut buf, &mut out);
120    }
121
122    if !saw_marker {
123        // No markers: preserve exact legacy behavior (whole text → task).
124        let mut out = HashMap::new();
125        let trimmed = text.trim();
126        if !trimmed.is_empty() {
127            out.insert("task".to_string(), trimmed.to_string());
128        }
129        return out;
130    }
131    out
132}
133
134/// The stop reason to report for a run that has reached `status`, or `None`
135/// while the run has not stopped at all.
136///
137/// `Error` maps to `refusal` rather than inventing a failure code: the
138/// protocol has no "the agent broke" reason, and `refusal` is the only one
139/// that tells the host the turn produced no usable answer. Non-terminal
140/// statuses yield `None` so a poller can distinguish "still running" from
141/// "ended" without a second status check.
142pub fn stop_reason_for(status: &RunStatus) -> Option<StopReason> {
143    match status {
144        RunStatus::Complete | RunStatus::CompleteInteractive => Some(StopReason::EndTurn),
145        RunStatus::Error => Some(StopReason::Refusal),
146        RunStatus::Cancelled => Some(StopReason::Cancelled),
147        RunStatus::Starting | RunStatus::Running | RunStatus::WaitingInput | RunStatus::Paused => {
148            None
149        }
150    }
151}
152
153/// [`stop_reason_for`] over the string labels carried by completion events,
154/// which report a terminal status by name rather than as a [`RunStatus`].
155/// An unexpected label reads as an ordinary end of turn.
156pub fn stop_reason_for_label(status: &str) -> StopReason {
157    match status {
158        "cancelled" => StopReason::Cancelled,
159        "error" => StopReason::Refusal,
160        _ => StopReason::EndTurn,
161    }
162}
163
164/// Build a `session/request_permission` request from a Leviath tool-approval
165/// interaction.
166///
167/// The offered options mirror what Leviath's own approval prompt supports:
168/// approve once, approve for the rest of the session
169/// ([`leviath_core::interaction::ApprovalScope::Run`]), or reject. There is
170/// deliberately no "reject always" - Leviath has no persistent per-tool denylist
171/// to record it in, and offering a choice we cannot honour would be a lie.
172pub fn permission_request(
173    session_id: &str,
174    request: &InteractionRequest,
175) -> RequestPermissionParams {
176    RequestPermissionParams {
177        session_id: session_id.to_string(),
178        tool_call: ToolCallRef {
179            tool_call_id: request.id.clone(),
180            title: permission_title(request),
181            kind: tool_kind_for(request.tool_name.as_deref()),
182            status: ToolCallStatus::Pending,
183        },
184        options: vec![
185            PermissionOption {
186                option_id: OPTION_ALLOW_ONCE.to_string(),
187                name: "Allow once".to_string(),
188                kind: PermissionOptionKind::AllowOnce,
189            },
190            PermissionOption {
191                option_id: OPTION_ALLOW_ALWAYS.to_string(),
192                name: "Allow for this session".to_string(),
193                kind: PermissionOptionKind::AllowAlways,
194            },
195            PermissionOption {
196                option_id: OPTION_REJECT_ONCE.to_string(),
197                name: "Reject".to_string(),
198                kind: PermissionOptionKind::RejectOnce,
199            },
200        ],
201    }
202}
203
204/// A one-line summary of the tool call awaiting approval: the tool name when the
205/// request carries one, else the prompt Leviath would have shown a human.
206fn permission_title(request: &InteractionRequest) -> String {
207    match request.tool_name.as_deref() {
208        Some(name) => name.to_string(),
209        None => request.prompt.clone(),
210    }
211}
212
213/// Classify a Leviath tool name into the protocol's tool-kind taxonomy, so hosts
214/// can pick an icon and phrase the approval prompt.
215///
216/// Unrecognised names - including every MCP tool, whose names are arbitrary -
217/// fall back to [`ToolKind::Other`].
218fn tool_kind_for(tool_name: Option<&str>) -> ToolKind {
219    match tool_name {
220        Some("read_file" | "read_files" | "list_files" | "grep") => ToolKind::Read,
221        Some("write_file" | "edit_file" | "apply_patch") => ToolKind::Edit,
222        Some("delete_file") => ToolKind::Delete,
223        Some("move_file") => ToolKind::Move,
224        Some("search" | "web_search") => ToolKind::Search,
225        Some("bash" | "run_command" | "shell") => ToolKind::Execute,
226        Some("fetch" | "web_fetch" | "http_get") => ToolKind::Fetch,
227        _ => ToolKind::Other,
228    }
229}
230
231/// Whether an interaction can be answered over the protocol at all.
232///
233/// Only [`InteractionKind::ToolApproval`] maps onto
234/// `session/request_permission`. Free-text questions, multiple choice, confirms
235/// and in-place document edits have no protocol equivalent, so the server
236/// surfaces those as agent output and lets the next `session/prompt` carry the
237/// answer.
238pub fn is_permission_request(request: &InteractionRequest) -> bool {
239    matches!(request.kind, InteractionKind::ToolApproval)
240}
241
242#[cfg(test)]
243mod tests {
244    use super::*;
245    use crate::protocol::EmbeddedResource;
246
247    fn text_block(text: &str) -> ContentBlock {
248        ContentBlock::text(text)
249    }
250
251    fn resource_block(uri: &str, text: Option<&str>) -> ContentBlock {
252        ContentBlock {
253            kind: "resource".to_string(),
254            text: None,
255            resource: Some(EmbeddedResource {
256                uri: uri.to_string(),
257                mime_type: None,
258                text: text.map(str::to_string),
259            }),
260        }
261    }
262
263    fn approval(id: &str, tool: Option<&str>) -> InteractionRequest {
264        InteractionRequest {
265            id: id.to_string(),
266            kind: InteractionKind::ToolApproval,
267            prompt: "Run this?".to_string(),
268            options: vec![],
269            tool_name: tool.map(str::to_string),
270            tool_arguments: None,
271            required: true,
272            stage_name: "implement".to_string(),
273            body: None,
274            body_format: Default::default(),
275        }
276    }
277
278    // ─── parse_region_markers ────────────────────────────────────────────────
279
280    #[test]
281    fn markers_absent_puts_whole_text_in_task() {
282        let out = parse_region_markers("just do the thing");
283        assert_eq!(out.len(), 1);
284        assert_eq!(
285            out.get("task").map(String::as_str),
286            Some("just do the thing")
287        );
288    }
289
290    #[test]
291    fn markers_absent_empty_text_yields_empty_map() {
292        assert!(parse_region_markers("   \n  ").is_empty());
293    }
294
295    #[test]
296    fn leading_text_before_first_marker_becomes_task() {
297        let text = "build a parser\n---region:criteria---\nfocus on safety";
298        let out = parse_region_markers(text);
299        assert_eq!(out.get("task").map(String::as_str), Some("build a parser"));
300        assert_eq!(
301            out.get("criteria").map(String::as_str),
302            Some("focus on safety")
303        );
304    }
305
306    #[test]
307    fn multiple_regions_and_end_marker_with_trailing_text_dropped() {
308        let text = "\
309---region:task---
310build it
311---region:criteria---
312be careful
313---end-regions---
314this trailing text is ignored";
315        let out = parse_region_markers(text);
316        assert_eq!(out.get("task").map(String::as_str), Some("build it"));
317        assert_eq!(out.get("criteria").map(String::as_str), Some("be careful"));
318        assert_eq!(out.len(), 2, "trailing text after end marker is dropped");
319    }
320
321    #[test]
322    fn empty_region_blocks_are_dropped() {
323        let text = "---region:task---\nreal\n---region:empty---\n   \n";
324        let out = parse_region_markers(text);
325        assert_eq!(out.get("task").map(String::as_str), Some("real"));
326        assert!(!out.contains_key("empty"));
327    }
328
329    // ─── flatten_prompt ──────────────────────────────────────────────────────
330
331    #[test]
332    fn flatten_joins_text_blocks_with_a_blank_line() {
333        assert_eq!(
334            flatten_prompt(&[text_block("first"), text_block("second")]),
335            "first\n\nsecond"
336        );
337    }
338
339    #[test]
340    fn flatten_of_a_single_block_is_just_its_text() {
341        assert_eq!(flatten_prompt(&[text_block("only")]), "only");
342    }
343
344    #[test]
345    fn flatten_skips_blank_and_whitespace_only_text_blocks() {
346        assert_eq!(
347            flatten_prompt(&[text_block(""), text_block("  \n "), text_block("real")]),
348            "real"
349        );
350    }
351
352    #[test]
353    fn flatten_trims_each_text_block() {
354        assert_eq!(flatten_prompt(&[text_block("  padded  ")]), "padded");
355    }
356
357    #[test]
358    fn flatten_skips_a_text_block_with_no_text_field() {
359        let block = ContentBlock {
360            kind: "text".to_string(),
361            text: None,
362            resource: None,
363        };
364        assert_eq!(flatten_prompt(&[block, text_block("kept")]), "kept");
365    }
366
367    #[test]
368    fn flatten_headers_resource_blocks_with_their_uri() {
369        assert_eq!(
370            flatten_prompt(&[
371                text_block("review this"),
372                resource_block("file:///a.rs", Some("fn main() {}")),
373            ]),
374            "review this\n\n--- file:///a.rs ---\nfn main() {}"
375        );
376    }
377
378    #[test]
379    fn flatten_skips_a_resource_block_with_no_inlined_text() {
380        assert_eq!(
381            flatten_prompt(&[resource_block("file:///a.rs", None), text_block("kept")]),
382            "kept"
383        );
384    }
385
386    #[test]
387    fn flatten_skips_a_resource_block_with_no_resource_field() {
388        let block = ContentBlock {
389            kind: "resource".to_string(),
390            text: None,
391            resource: None,
392        };
393        assert_eq!(flatten_prompt(&[block, text_block("kept")]), "kept");
394    }
395
396    #[test]
397    fn flatten_drops_unsupported_block_kinds() {
398        let image = ContentBlock {
399            kind: "image".to_string(),
400            text: Some("ignored".to_string()),
401            resource: None,
402        };
403        assert_eq!(flatten_prompt(&[image, text_block("kept")]), "kept");
404    }
405
406    #[test]
407    fn flatten_of_nothing_usable_is_empty() {
408        assert_eq!(flatten_prompt(&[]), "");
409        let audio = ContentBlock {
410            kind: "audio".to_string(),
411            text: None,
412            resource: None,
413        };
414        assert_eq!(flatten_prompt(&[audio]), "");
415    }
416
417    // ─── stop_reason_for ─────────────────────────────────────────────────────
418
419    #[test]
420    fn stop_reason_maps_every_run_status() {
421        for (status, expected) in [
422            (RunStatus::Starting, None),
423            (RunStatus::Running, None),
424            (RunStatus::WaitingInput, None),
425            (RunStatus::Paused, None),
426            (RunStatus::Complete, Some(StopReason::EndTurn)),
427            (RunStatus::CompleteInteractive, Some(StopReason::EndTurn)),
428            (RunStatus::Error, Some(StopReason::Refusal)),
429            (RunStatus::Cancelled, Some(StopReason::Cancelled)),
430        ] {
431            assert_eq!(stop_reason_for(&status), expected, "status {status}");
432        }
433    }
434
435    #[test]
436    fn stop_reason_label_matches_the_status_mapping() {
437        assert_eq!(stop_reason_for_label("cancelled"), StopReason::Cancelled);
438        assert_eq!(stop_reason_for_label("error"), StopReason::Refusal);
439        assert_eq!(stop_reason_for_label("complete"), StopReason::EndTurn);
440        assert_eq!(stop_reason_for_label("anything-else"), StopReason::EndTurn);
441    }
442
443    // ─── permission_request ──────────────────────────────────────────────────
444
445    #[test]
446    fn permission_request_offers_once_session_and_reject() {
447        let params = permission_request("s1", &approval("q1", Some("bash")));
448        assert_eq!(params.session_id, "s1");
449        assert_eq!(params.tool_call.tool_call_id, "q1");
450        assert_eq!(params.tool_call.title, "bash");
451        assert_eq!(params.tool_call.kind, ToolKind::Execute);
452        assert_eq!(params.tool_call.status, ToolCallStatus::Pending);
453        let ids: Vec<&str> = params
454            .options
455            .iter()
456            .map(|o| o.option_id.as_str())
457            .collect();
458        assert_eq!(
459            ids,
460            [OPTION_ALLOW_ONCE, OPTION_ALLOW_ALWAYS, OPTION_REJECT_ONCE]
461        );
462        let kinds: Vec<PermissionOptionKind> = params.options.iter().map(|o| o.kind).collect();
463        assert_eq!(
464            kinds,
465            [
466                PermissionOptionKind::AllowOnce,
467                PermissionOptionKind::AllowAlways,
468                PermissionOptionKind::RejectOnce,
469            ]
470        );
471    }
472
473    #[test]
474    fn permission_request_falls_back_to_the_prompt_when_there_is_no_tool_name() {
475        let params = permission_request("s1", &approval("q1", None));
476        assert_eq!(params.tool_call.title, "Run this?");
477        assert_eq!(params.tool_call.kind, ToolKind::Other);
478    }
479
480    #[test]
481    fn tool_kinds_cover_every_classification_arm() {
482        for (name, expected) in [
483            ("read_file", ToolKind::Read),
484            ("read_files", ToolKind::Read),
485            ("list_files", ToolKind::Read),
486            ("grep", ToolKind::Read),
487            ("write_file", ToolKind::Edit),
488            ("edit_file", ToolKind::Edit),
489            ("apply_patch", ToolKind::Edit),
490            ("delete_file", ToolKind::Delete),
491            ("move_file", ToolKind::Move),
492            ("search", ToolKind::Search),
493            ("web_search", ToolKind::Search),
494            ("bash", ToolKind::Execute),
495            ("run_command", ToolKind::Execute),
496            ("shell", ToolKind::Execute),
497            ("fetch", ToolKind::Fetch),
498            ("web_fetch", ToolKind::Fetch),
499            ("http_get", ToolKind::Fetch),
500            ("mcp__whatever__thing", ToolKind::Other),
501        ] {
502            assert_eq!(tool_kind_for(Some(name)), expected, "tool {name}");
503        }
504        assert_eq!(tool_kind_for(None), ToolKind::Other);
505    }
506
507    // ─── is_permission_request ───────────────────────────────────────────────
508
509    #[test]
510    fn only_tool_approvals_are_permission_requests() {
511        assert!(is_permission_request(&approval("q", Some("bash"))));
512        for kind in [
513            InteractionKind::FreeText,
514            InteractionKind::MultipleChoice,
515            InteractionKind::Confirm,
516            InteractionKind::EditText,
517        ] {
518            let mut req = approval("q", None);
519            req.kind = kind;
520            assert!(!is_permission_request(&req));
521        }
522    }
523}