Skip to main content

perspt_sdk/model/
conversation.rs

1//! The harness-owned message log (PSP-9 system 4).
2//!
3//! `Conversation` is Perspt's own log rather than a vendor type: it is
4//! re-rendered per provider on each turn, which is what makes a mid-run
5//! provider swap possible at all. Closed tool-call/result pairs may later be
6//! replaced by content-addressed references (system 14); the full arguments
7//! and results always remain in the ledger.
8
9use serde::{Deserialize, Serialize};
10
11use super::tool::ProviderToolCall;
12
13/// One entry in the harness conversation log.
14#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
15#[serde(rename_all = "snake_case", tag = "role")]
16pub enum Message {
17    System {
18        content: String,
19    },
20    User {
21        content: String,
22    },
23    Assistant {
24        content: String,
25    },
26    /// An assistant turn that requested tool calls.
27    AssistantToolCalls {
28        calls: Vec<ProviderToolCall>,
29    },
30    /// The recorded result of one tool call, keyed by its call id.
31    ToolResponse {
32        call_id: String,
33        content: String,
34    },
35}
36
37/// The message log the harness owns: system, user, assistant,
38/// assistant-tool-calls, tool-response. Never a vendor type.
39#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
40pub struct Conversation {
41    messages: Vec<Message>,
42}
43
44impl Conversation {
45    /// Start a conversation with a system prompt.
46    pub fn with_system(prompt: impl Into<String>) -> Self {
47        Self {
48            messages: vec![Message::System {
49                content: prompt.into(),
50            }],
51        }
52    }
53
54    /// Append a message.
55    pub fn push(&mut self, message: Message) {
56        self.messages.push(message);
57    }
58
59    /// Append a user message (goals, directed corrections).
60    pub fn push_user(&mut self, content: impl Into<String>) {
61        self.push(Message::User {
62            content: content.into(),
63        });
64    }
65
66    /// Append an assistant tool-call turn.
67    pub fn push_tool_calls(&mut self, calls: Vec<ProviderToolCall>) {
68        self.push(Message::AssistantToolCalls { calls });
69    }
70
71    /// Append one tool response, correlated by call id.
72    pub fn push_tool_response(&mut self, call_id: impl Into<String>, content: impl Into<String>) {
73        self.push(Message::ToolResponse {
74            call_id: call_id.into(),
75            content: content.into(),
76        });
77    }
78
79    /// The log, in order.
80    pub fn messages(&self) -> &[Message] {
81        &self.messages
82    }
83
84    /// Approximate serialized context size without relying on a provider
85    /// tokenizer. Runtime thresholds apply a conservative chars-per-token
86    /// conversion before calling this method.
87    pub fn estimated_chars(&self) -> usize {
88        self.messages
89            .iter()
90            .map(|message| serde_json::to_string(message).map_or(0, |value| value.len()))
91            .sum()
92    }
93
94    /// Replace closed history with a deterministic control projection while
95    /// preserving every unresolved provider call exactly. Full closed history
96    /// remains in the event ledger; this only changes the next model request.
97    pub fn compact_with_control(&mut self, control: impl Into<String>) {
98        let unresolved = self.unresolved_call_ids();
99        let mut projected = Vec::new();
100        if let Some(system) = self
101            .messages
102            .iter()
103            .find(|message| matches!(message, Message::System { .. }))
104        {
105            projected.push(system.clone());
106        }
107        projected.push(Message::System {
108            content: control.into(),
109        });
110        if let Some(user) = self
111            .messages
112            .iter()
113            .rev()
114            .find(|message| matches!(message, Message::User { .. }))
115        {
116            projected.push(user.clone());
117        }
118        for message in &self.messages {
119            if let Message::AssistantToolCalls { calls } = message {
120                let calls: Vec<_> = calls
121                    .iter()
122                    .filter(|call| unresolved.contains(&call.call_id))
123                    .cloned()
124                    .collect();
125                if !calls.is_empty() {
126                    projected.push(Message::AssistantToolCalls { calls });
127                }
128            }
129        }
130        self.messages = projected;
131    }
132
133    /// Tool calls that have no recorded response yet. A checkpoint must
134    /// preserve these exactly (system 14's `ControlFrame`).
135    pub fn unresolved_call_ids(&self) -> Vec<String> {
136        let mut open: Vec<String> = Vec::new();
137        for message in &self.messages {
138            match message {
139                Message::AssistantToolCalls { calls } => {
140                    open.extend(calls.iter().map(|c| c.call_id.clone()));
141                }
142                Message::ToolResponse { call_id, .. } => {
143                    open.retain(|id| id != call_id);
144                }
145                _ => {}
146            }
147        }
148        open
149    }
150
151    /// Number of messages in the log.
152    pub fn len(&self) -> usize {
153        self.messages.len()
154    }
155
156    /// Whether the log is empty.
157    pub fn is_empty(&self) -> bool {
158        self.messages.is_empty()
159    }
160}
161
162#[cfg(test)]
163mod tests {
164    use super::*;
165
166    fn call(id: &str) -> ProviderToolCall {
167        ProviderToolCall {
168            call_id: id.into(),
169            name: "read_file".into(),
170            arguments: serde_json::json!({}),
171        }
172    }
173
174    #[test]
175    fn tracks_unresolved_tool_calls() {
176        let mut conversation = Conversation::with_system("harness");
177        conversation.push_tool_calls(vec![call("a"), call("b")]);
178        conversation.push_tool_response("a", "contents");
179        assert_eq!(conversation.unresolved_call_ids(), ["b"]);
180        conversation.push_tool_response("b", "denied");
181        assert!(conversation.unresolved_call_ids().is_empty());
182    }
183
184    #[test]
185    fn conversation_is_vendor_neutral_serializable() {
186        let mut conversation = Conversation::with_system("s");
187        conversation.push_user("fix the test");
188        let json = serde_json::to_string(&conversation).unwrap();
189        let back: Conversation = serde_json::from_str(&json).unwrap();
190        assert_eq!(back, conversation);
191    }
192
193    #[test]
194    fn compaction_drops_closed_history_but_preserves_open_calls() {
195        let mut conversation = Conversation::with_system("harness");
196        conversation.push_user("large goal");
197        conversation.push_tool_calls(vec![call("closed"), call("open")]);
198        conversation.push_tool_response("closed", "contents");
199        conversation.compact_with_control("CONTROL");
200
201        assert_eq!(conversation.unresolved_call_ids(), ["open"]);
202        assert_eq!(conversation.len(), 4);
203        assert!(matches!(
204            &conversation.messages()[3],
205            Message::AssistantToolCalls { calls } if calls == &vec![call("open")]
206        ));
207    }
208}