Skip to main content

robonix_pilot/
history.rs

1// SPDX-License-Identifier: MulanPSL-2.0
2// Author: wheatfox <wheatfox17@icloud.com>
3//
4// Two concerns live here:
5//   1. Mapping an executor tool result (JSON string) back into one or more
6//      `Message`s the LLM can ingest. OpenAI-compatible endpoints reject
7//      images on `tool` role, so when a tool returns an image we keep the
8//      tool result textual and append a synthetic `user` vision message.
9//   2. Pre-flight cleanup of `Vec<Message>` before we hand it to the LLM:
10//      trim to MAX_HISTORY and drop tool messages whose preceding assistant
11//      tool_call was already evicted (which would otherwise be rejected).
12
13use crate::vlm::Message;
14use std::collections::HashSet;
15
16/// Output of `tool_result_to_messages`: messages that go in `tool` role,
17/// plus optional follow-up `user` messages (e.g. for an image attachment).
18pub struct ToolResultHistory {
19    pub tool_messages: Vec<Message>,
20    pub followup_messages: Vec<Message>,
21}
22
23fn is_image_value(v: &serde_json::Value) -> bool {
24    v.get("image_base64")
25        .and_then(|x| x.as_str())
26        .is_some_and(|data| !data.is_empty())
27        || (v.get("width").is_some()
28            && v.get("height").is_some()
29            && v.get("encoding")
30                .and_then(|encoding| encoding.as_str())
31                .is_some_and(|encoding| encoding != "error")
32            && v.get("data")
33                .and_then(|data| data.as_str())
34                .is_some_and(|data| !data.is_empty()))
35}
36
37/// Return true only for payloads that [`tool_result_to_messages`] will map to
38/// an actual image follow-up. Callers use this to avoid retaining malformed
39/// image-shaped JSON without normal result-size bounds.
40pub fn is_image_output(output: &str) -> bool {
41    serde_json::from_str::<serde_json::Value>(output).is_ok_and(|value| is_image_value(&value))
42}
43
44/// Build history messages from one executor tool result.
45///
46/// Normal path: a single `Message` with `role: "tool"` and `tool_call_id = call_id`,
47/// carrying `output` (or a short placeholder) in `content`.
48///
49/// Image path: OpenAI-compatible APIs do not accept image payloads on `tool` messages.
50/// We still emit a `tool` line with a text placeholder, then add a synthetic `user`
51/// message with `image_base64` so `build_openai_messages` can attach a vision part.
52pub fn tool_result_to_messages(call_id: &str, output: &str) -> ToolResultHistory {
53    // One `tool` message in the typical case; image-shaped results add follow-up `user`
54    // vision messages (see doc above).
55    let Ok(v) = serde_json::from_str::<serde_json::Value>(output) else {
56        return ToolResultHistory {
57            tool_messages: vec![Message::tool_result(call_id, output)],
58            followup_messages: vec![],
59        };
60    };
61
62    if is_image_value(&v)
63        && let Some(b64) = v.get("image_base64").and_then(|x| x.as_str())
64    {
65        let fmt = v.get("format").and_then(|x| x.as_str()).unwrap_or("jpeg");
66        return ToolResultHistory {
67            tool_messages: vec![Message::tool_result(
68                call_id,
69                &format!("[{fmt} image attached]"),
70            )],
71            followup_messages: vec![Message::user_with_image(
72                "Executor feedback: the previous capability call returned this image. Use it as observation data for the current task; this is not a new user request.",
73                b64.to_string(),
74            )],
75        };
76    }
77
78    // sensor_msgs/msg/Image — matches camera_snapshot / camera_depth_snapshot.
79    // Skip encoding="error" (placeholder) and any payload missing real data.
80    let img_encoding = v.get("encoding").and_then(|e| e.as_str());
81    if is_image_value(&v) && img_encoding.is_some() {
82        let enc = img_encoding.unwrap_or("jpeg");
83        let b64 = v.get("data").and_then(|d| d.as_str()).unwrap_or("");
84        return ToolResultHistory {
85            tool_messages: vec![Message::tool_result(
86                call_id,
87                &format!("[sensor_msgs/Image encoding={enc}]"),
88            )],
89            followup_messages: vec![Message::user_with_image(
90                "Executor feedback: the previous capability call returned this image. Use it as observation data for the current task; this is not a new user request.",
91                b64.to_string(),
92            )],
93        };
94    }
95
96    ToolResultHistory {
97        tool_messages: vec![Message::tool_result(call_id, output)],
98        followup_messages: vec![],
99    }
100}
101
102/// Drop the oldest messages so `history.len() <= max`. No-op if already short.
103pub fn trim(history: &mut Vec<Message>, max: usize) {
104    if history.len() > max {
105        let remove = history.len() - max;
106        history.drain(0..remove);
107    }
108}
109
110/// Filter `history` to a form OpenAI-compatible endpoints accept:
111/// every `tool` message must be preceded by an `assistant` whose
112/// `tool_calls` lists its `tool_call_id`. Orphans (e.g. left over from
113/// a trim that dropped the assistant) are quietly removed.
114pub fn sanitize_for_vlm(history: &[Message]) -> Vec<Message> {
115    let mut out: Vec<Message> = Vec::with_capacity(history.len());
116    let mut open_tool_call_ids: HashSet<String> = Default::default();
117
118    for msg in history {
119        match msg.role.as_str() {
120            "assistant" => {
121                open_tool_call_ids.clear();
122                if let Some(calls) = &msg.tool_calls {
123                    for tc in calls {
124                        open_tool_call_ids.insert(tc.id.clone());
125                    }
126                }
127                out.push(msg.clone());
128            }
129            "tool" => {
130                let Some(call_id) = msg.tool_call_id.as_ref() else {
131                    continue;
132                };
133                if open_tool_call_ids.remove(call_id) {
134                    out.push(msg.clone());
135                }
136            }
137            _ => {
138                open_tool_call_ids.clear();
139                out.push(msg.clone());
140            }
141        }
142    }
143    out
144}