Skip to main content

honcho_ai/types/
session.rs

1//! Session-related types for the Honcho API.
2
3use std::collections::HashMap;
4
5use chrono::{DateTime, Utc};
6use serde::{Deserialize, Serialize};
7
8pub use super::common::{
9    DreamConfiguration, PeerCardConfiguration, ReasoningConfiguration, SummaryConfiguration,
10};
11pub use super::dream::SessionQueueStatus;
12
13/// A conversation session containing messages between peers.
14#[non_exhaustive]
15#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
16pub struct SessionResponse {
17    /// Unique session identifier.
18    pub id: String,
19    /// Whether the session is currently active.
20    pub is_active: bool,
21    /// The workspace this session belongs to.
22    pub workspace_id: String,
23    /// Arbitrary key-value metadata attached to the session.
24    #[serde(default, skip_serializing_if = "HashMap::is_empty")]
25    pub metadata: HashMap<String, serde_json::Value>,
26    /// Session-level configuration overrides.
27    #[serde(default)]
28    pub configuration: SessionConfiguration,
29    /// When the session was created.
30    pub created_at: DateTime<Utc>,
31}
32
33/// Request body for creating a new session.
34#[non_exhaustive]
35#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, bon::Builder)]
36#[builder(on(String, into))]
37#[builder(finish_fn = build)]
38pub struct SessionCreate {
39    /// Unique session identifier (alphanumeric, hyphens, underscores).
40    pub id: String,
41    /// Optional metadata to attach.
42    #[serde(skip_serializing_if = "Option::is_none")]
43    pub metadata: Option<HashMap<String, serde_json::Value>>,
44    /// Peer configurations keyed by peer ID.
45    #[serde(skip_serializing_if = "Option::is_none")]
46    pub peers: Option<HashMap<String, SessionPeerConfig>>,
47    /// Optional session-level configuration.
48    #[serde(skip_serializing_if = "Option::is_none")]
49    pub configuration: Option<SessionConfiguration>,
50}
51
52impl SessionCreate {
53    /// Validate session ID constraints.
54    pub fn validate(&self) -> crate::error::Result<()> {
55        if self.id.is_empty() {
56            return Err(crate::error::HonchoError::Validation(
57                "session id must not be empty".into(),
58            ));
59        }
60        if !self
61            .id
62            .chars()
63            .all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_')
64        {
65            return Err(crate::error::HonchoError::Validation(
66                "session id must contain only [a-zA-Z0-9_-]".into(),
67            ));
68        }
69        Ok(())
70    }
71}
72
73/// Request body for updating a session.
74#[non_exhaustive]
75#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, bon::Builder)]
76#[builder(on(String, into))]
77#[builder(finish_fn = build)]
78pub struct SessionUpdate {
79    /// Updated metadata (replaces existing).
80    #[serde(skip_serializing_if = "Option::is_none")]
81    pub metadata: Option<HashMap<String, serde_json::Value>>,
82    /// Updated session configuration (merges with existing).
83    #[serde(skip_serializing_if = "Option::is_none")]
84    pub configuration: Option<SessionConfiguration>,
85}
86
87/// Query parameters for listing/getting sessions.
88#[non_exhaustive]
89#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, bon::Builder)]
90#[builder(on(String, into))]
91#[builder(finish_fn = build)]
92pub struct SessionGet {
93    /// Filter criteria for sessions.
94    #[serde(skip_serializing_if = "Option::is_none")]
95    pub filters: Option<HashMap<String, serde_json::Value>>,
96}
97
98/// Request body for setting session metadata.
99#[non_exhaustive]
100#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
101pub struct SessionMetadataSet {
102    /// Metadata to set.
103    pub metadata: HashMap<String, serde_json::Value>,
104}
105
106/// Request body for setting session configuration.
107#[non_exhaustive]
108#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
109pub struct SessionConfigurationSet {
110    /// Configuration to set.
111    pub configuration: HashMap<String, serde_json::Value>,
112}
113
114/// Session-level configuration overrides.
115///
116/// All fields are optional. Session-level configuration overrides
117/// workspace-level configuration, which overrides global configuration.
118#[non_exhaustive]
119#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
120pub struct SessionConfiguration {
121    /// Configuration for reasoning functionality.
122    #[serde(skip_serializing_if = "Option::is_none")]
123    pub reasoning: Option<ReasoningConfiguration>,
124    /// Configuration for peer card functionality.
125    ///
126    /// If reasoning is disabled, peer cards will also be disabled
127    /// and these settings will be ignored.
128    #[serde(skip_serializing_if = "Option::is_none")]
129    pub peer_card: Option<PeerCardConfiguration>,
130    /// Configuration for summary functionality.
131    #[serde(skip_serializing_if = "Option::is_none")]
132    pub summary: Option<SummaryConfiguration>,
133    /// Configuration for dream functionality.
134    ///
135    /// If reasoning is disabled, dreams will also be disabled
136    /// and these settings will be ignored.
137    #[serde(skip_serializing_if = "Option::is_none")]
138    pub dream: Option<DreamConfiguration>,
139}
140
141/// Per-peer observation settings within a session.
142#[non_exhaustive]
143#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
144pub struct SessionPeerConfig {
145    /// Whether Honcho will use reasoning to form a representation of this peer.
146    #[serde(skip_serializing_if = "Option::is_none")]
147    pub observe_me: Option<bool>,
148    /// Whether this peer should form session-level representations of other peers.
149    #[serde(skip_serializing_if = "Option::is_none")]
150    pub observe_others: Option<bool>,
151}
152
153/// Options for `Session::context_with_options`.
154#[non_exhaustive]
155#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, bon::Builder)]
156#[builder(on(String, into))]
157#[builder(finish_fn = build)]
158pub struct SessionContextOptions {
159    /// Whether to include the session summary.
160    #[serde(default = "default_true")]
161    #[builder(default = true)]
162    pub summary: bool,
163    /// Whether to limit representation context to this session only.
164    #[serde(default)]
165    #[builder(default)]
166    pub limit_to_session: bool,
167    /// Maximum number of tokens to include in the context.
168    #[serde(default, skip_serializing_if = "Option::is_none")]
169    pub tokens: Option<u32>,
170    /// A peer ID to get context for.
171    #[serde(default, skip_serializing_if = "Option::is_none")]
172    pub peer_target: Option<String>,
173    /// A peer ID to get context from the perspective of.
174    #[serde(default, skip_serializing_if = "Option::is_none")]
175    pub peer_perspective: Option<String>,
176    /// A query string used to fetch semantically relevant conclusions.
177    #[serde(default, skip_serializing_if = "Option::is_none")]
178    pub search_query: Option<String>,
179    /// Number of semantically relevant facts to return when searching.
180    #[serde(default, skip_serializing_if = "Option::is_none")]
181    pub search_top_k: Option<u32>,
182    /// Maximum semantic distance for search results (0.0–1.0).
183    #[serde(default, skip_serializing_if = "Option::is_none")]
184    pub search_max_distance: Option<f64>,
185    /// Whether to include the most frequent conclusions.
186    #[serde(default, skip_serializing_if = "Option::is_none")]
187    pub include_most_frequent: Option<bool>,
188    /// Maximum number of conclusions to include.
189    #[serde(default, skip_serializing_if = "Option::is_none")]
190    pub max_conclusions: Option<u32>,
191}
192
193pub(crate) const SEARCH_TOP_K_MIN: u32 = 1;
194pub(crate) const SEARCH_TOP_K_MAX: u32 = 100;
195pub(crate) const SEARCH_MAX_DISTANCE_MIN: f64 = 0.0;
196pub(crate) const SEARCH_MAX_DISTANCE_MAX: f64 = 1.0;
197pub(crate) const MAX_CONCLUSIONS_MIN: u32 = 1;
198pub(crate) const MAX_CONCLUSIONS_MAX: u32 = 100;
199
200/// Validate optional search/conclusion parameters.
201///
202/// Checks that `search_top_k` is in 1–100, `search_max_distance` is in 0.0–1.0,
203/// and `max_conclusions` is in 1–100. Returns `Err(HonchoError::Validation)` on
204/// any out-of-range value.
205pub(crate) fn validate_search_params(
206    search_top_k: Option<u32>,
207    search_max_distance: Option<f64>,
208    max_conclusions: Option<u32>,
209) -> crate::error::Result<()> {
210    if let Some(k) = search_top_k
211        && !(SEARCH_TOP_K_MIN..=SEARCH_TOP_K_MAX).contains(&k)
212    {
213        return Err(crate::error::HonchoError::Validation(format!(
214            "search_top_k must be between {SEARCH_TOP_K_MIN} and {SEARCH_TOP_K_MAX}, got {k}"
215        )));
216    }
217    if let Some(d) = search_max_distance
218        && !(SEARCH_MAX_DISTANCE_MIN..=SEARCH_MAX_DISTANCE_MAX).contains(&d)
219    {
220        return Err(crate::error::HonchoError::Validation(format!(
221            "search_max_distance must be between {SEARCH_MAX_DISTANCE_MIN} and {SEARCH_MAX_DISTANCE_MAX}, got {d}"
222        )));
223    }
224    if let Some(c) = max_conclusions
225        && !(MAX_CONCLUSIONS_MIN..=MAX_CONCLUSIONS_MAX).contains(&c)
226    {
227        return Err(crate::error::HonchoError::Validation(format!(
228            "max_conclusions must be between {MAX_CONCLUSIONS_MIN} and {MAX_CONCLUSIONS_MAX}, got {c}"
229        )));
230    }
231    Ok(())
232}
233
234impl SessionContextOptions {
235    /// Validate cross-field constraints.
236    ///
237    /// Enforces that `peer_perspective` and `search_query` each require
238    /// `peer_target`, that range-bounded numeric fields are in range, and that
239    /// `tokens` (when set) is non-zero.
240    ///
241    /// Note: `search_top_k` and `search_max_distance` only take effect together
242    /// with `search_query`, and `max_conclusions` together with
243    /// `include_most_frequent`. They are intentionally NOT hard-rejected when
244    /// their companion is absent: the server ignores them in that case, and
245    /// rejecting locally would couple this client to server-side semantics that
246    /// may relax over time. Setting them without their companion is therefore a
247    /// no-op rather than an error.
248    pub fn validate(&self) -> crate::error::Result<()> {
249        if self.peer_perspective.is_some() && self.peer_target.is_none() {
250            return Err(crate::error::HonchoError::Validation(
251                "peer_perspective requires peer_target to be set".into(),
252            ));
253        }
254        if self.search_query.is_some() && self.peer_target.is_none() {
255            return Err(crate::error::HonchoError::Validation(
256                "search_query requires peer_target to be set".into(),
257            ));
258        }
259        validate_search_params(
260            self.search_top_k,
261            self.search_max_distance,
262            self.max_conclusions,
263        )?;
264        if let Some(t) = self.tokens
265            && t == 0
266        {
267            return Err(crate::error::HonchoError::Validation(
268                "tokens must be greater than 0".into(),
269            ));
270        }
271        Ok(())
272    }
273
274    /// Render the options as query-string key/value pairs.
275    ///
276    /// Returns borrowed `&'static str` keys and `Cow` values so that string
277    /// options (`peer_target`, `peer_perspective`, `search_query`) and boolean
278    /// literals are borrowed without allocation; only numeric values that must
279    /// be formatted are owned. This avoids the clone-then-discard round-trip a
280    /// `Vec<(&str, String)>` would force on the caller.
281    pub(crate) fn to_query_params(&self) -> Vec<(&'static str, std::borrow::Cow<'_, str>)> {
282        use std::borrow::Cow;
283
284        let mut params: Vec<(&'static str, Cow<'_, str>)> = vec![
285            (
286                "summary",
287                Cow::Borrowed(if self.summary { "true" } else { "false" }),
288            ),
289            (
290                "limit_to_session",
291                Cow::Borrowed(if self.limit_to_session {
292                    "true"
293                } else {
294                    "false"
295                }),
296            ),
297        ];
298        if let Some(v) = self.tokens {
299            params.push(("tokens", Cow::Owned(v.to_string())));
300        }
301        if let Some(ref v) = self.peer_target {
302            params.push(("peer_target", Cow::Borrowed(v.as_str())));
303        }
304        if let Some(ref v) = self.peer_perspective {
305            params.push(("peer_perspective", Cow::Borrowed(v.as_str())));
306        }
307        if let Some(ref v) = self.search_query {
308            params.push(("search_query", Cow::Borrowed(v.as_str())));
309        }
310        if let Some(v) = self.search_top_k {
311            params.push(("search_top_k", Cow::Owned(v.to_string())));
312        }
313        if let Some(v) = self.search_max_distance {
314            params.push(("search_max_distance", Cow::Owned(v.to_string())));
315        }
316        if let Some(v) = self.include_most_frequent {
317            params.push((
318                "include_most_frequent",
319                Cow::Borrowed(if v { "true" } else { "false" }),
320            ));
321        }
322        if let Some(v) = self.max_conclusions {
323            params.push(("max_conclusions", Cow::Owned(v.to_string())));
324        }
325        params
326    }
327}
328
329fn default_true() -> bool {
330    true
331}
332
333/// Escape a value before interpolating it into a pseudo-XML `<tag>…</tag>`
334/// wrapper.
335///
336/// Security control: context values (summaries, peer representations, peer
337/// cards) are influenced by session content and are therefore attacker-
338/// controllable. Without escaping, a value containing e.g. `</summary>` would
339/// break out of its tag framing, corrupting the message structure and enabling
340/// prompt injection. We HTML-escape `&`, `<`, and `>` (ampersand first, so the
341/// substitution stays unambiguous and reversible), which makes it impossible
342/// for a value to forge or close a tag.
343fn escape_tag_value(value: &str) -> std::borrow::Cow<'_, str> {
344    if value.contains(['&', '<', '>']) {
345        std::borrow::Cow::Owned(
346            value
347                .replace('&', "&amp;")
348                .replace('<', "&lt;")
349                .replace('>', "&gt;"),
350        )
351    } else {
352        std::borrow::Cow::Borrowed(value)
353    }
354}
355
356/// Context returned when requesting session state.
357#[non_exhaustive]
358#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
359pub struct SessionContext {
360    /// Session identifier.
361    pub id: String,
362    /// Messages in the session.
363    pub messages: Vec<super::message::MessageResponse>,
364    /// The summary if available.
365    #[serde(skip_serializing_if = "Option::is_none")]
366    pub summary: Option<Summary>,
367    /// Curated subset of a peer representation, if requested from a specific perspective.
368    #[serde(skip_serializing_if = "Option::is_none")]
369    pub peer_representation: Option<String>,
370    /// The peer card, if requested from a specific perspective.
371    #[serde(skip_serializing_if = "Option::is_none")]
372    pub peer_card: Option<Vec<String>>,
373}
374
375/// Summaries for a session (short and/or long).
376#[non_exhaustive]
377#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
378pub struct SessionSummaries {
379    /// Session identifier.
380    pub id: String,
381    /// The short summary if available.
382    #[serde(skip_serializing_if = "Option::is_none")]
383    pub short_summary: Option<Summary>,
384    /// The long summary if available.
385    #[serde(skip_serializing_if = "Option::is_none")]
386    pub long_summary: Option<Summary>,
387}
388
389/// The type of session summary.
390#[non_exhaustive]
391#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
392#[serde(rename_all = "snake_case")]
393pub enum SummaryType {
394    /// Short summary generated more frequently.
395    Short,
396    /// Long summary generated less frequently.
397    Long,
398    /// A summary type not recognized by this client version.
399    ///
400    /// Acts as a forward-compatibility catch-all: a new summary type added
401    /// server-side deserializes here instead of failing the whole
402    /// `SessionContext` deserialization.
403    #[serde(other)]
404    Unknown,
405}
406
407/// A session summary covering messages up to a point.
408#[non_exhaustive]
409#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
410pub struct Summary {
411    /// The summary text.
412    pub content: String,
413    /// The public ID of the message this summary covers up to.
414    pub message_id: String,
415    /// The type of summary.
416    pub summary_type: SummaryType,
417    /// When the summary was created.
418    pub created_at: DateTime<Utc>,
419    /// Number of tokens in the summary text.
420    pub token_count: u32,
421}
422
423/// Options for listing sessions with filters and pagination.
424#[non_exhaustive]
425#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, bon::Builder)]
426#[builder(on(String, into))]
427#[builder(finish_fn = build)]
428pub struct SessionListOptions {
429    /// Filter criteria for sessions.
430    #[serde(skip_serializing_if = "Option::is_none")]
431    pub filters: Option<HashMap<String, serde_json::Value>>,
432    /// Page number (1-based).
433    #[serde(default = "default_page")]
434    #[builder(default = default_page())]
435    pub page: u64,
436    /// Page size. Must be in `1..=100`.
437    #[serde(default = "default_size")]
438    #[builder(default = default_size())]
439    pub size: u64,
440    /// Reverse order.
441    #[serde(default)]
442    #[builder(default)]
443    pub reverse: bool,
444}
445
446fn default_page() -> u64 {
447    1
448}
449
450fn default_size() -> u64 {
451    50
452}
453
454/// A paginated list of sessions.
455pub type SessionPage = super::pagination::Page<SessionResponse>;
456
457/// Resolves an assistant name from various reference types.
458///
459/// Implemented for `&str`, `String`, and `&Peer` so that
460/// [`SessionContext::to_openai`] and [`SessionContext::to_anthropic`]
461/// can accept any of these without extra boilerplate.
462// NOTE: trait name retained for public-API stability; any rename is a
463// separate breaking change deferred to a future major release.
464pub trait IntoAssistantRef {
465    /// Return the string name/id to use as the assistant.
466    fn as_assistant_name(&self) -> &str;
467}
468
469impl IntoAssistantRef for &str {
470    fn as_assistant_name(&self) -> &str {
471        self
472    }
473}
474
475impl IntoAssistantRef for String {
476    fn as_assistant_name(&self) -> &str {
477        self.as_str()
478    }
479}
480
481impl IntoAssistantRef for &crate::Peer {
482    fn as_assistant_name(&self) -> &str {
483        self.id()
484    }
485}
486
487impl SessionContext {
488    /// Format a peer card into a single displayable string.
489    ///
490    /// Each item is single-quoted with backslash escaping. The backslash is
491    /// escaped FIRST, then the single quote, so the output is unambiguous and
492    /// parseable: a trailing `\` cannot consume the closing quote (e.g. `foo\`
493    /// becomes `'foo\\'`, not `'foo\'`).
494    fn format_peer_card(card: &[String]) -> String {
495        let items: Vec<String> = card
496            .iter()
497            .map(|s| format!("'{}'", s.replace('\\', "\\\\").replace('\'', "\\'")))
498            .collect();
499        format!("[{}]", items.join(", "))
500    }
501
502    /// Build context system messages shared across provider formats.
503    ///
504    /// Returns `(content_tag, content_value)` pairs for `peer_representation`,
505    /// `peer_card`, and `summary`, in that order.
506    fn build_context_messages(&self) -> Vec<(&'static str, std::borrow::Cow<'_, str>)> {
507        let mut msgs = Vec::new();
508        if let Some(ref rep) = self.peer_representation {
509            msgs.push((
510                "peer_representation",
511                std::borrow::Cow::Borrowed(rep.as_str()),
512            ));
513        }
514        if let Some(ref card) = self.peer_card {
515            msgs.push((
516                "peer_card",
517                std::borrow::Cow::Owned(Self::format_peer_card(card)),
518            ));
519        }
520        if let Some(ref summary) = self.summary {
521            msgs.push((
522                "summary",
523                std::borrow::Cow::Borrowed(summary.content.as_str()),
524            ));
525        }
526        msgs
527    }
528
529    /// Build a provider message list.
530    ///
531    /// Shared by [`Self::to_openai`] and [`Self::to_anthropic`]: it emits the
532    /// tag-wrapped context messages with the given `context_role` (escaping
533    /// each value via [`escape_tag_value`] so session-derived content cannot
534    /// break out of the `<tag>…</tag>` framing), then delegates per-message
535    /// rendering to `render_message`, which receives the message and whether it
536    /// belongs to the assistant.
537    fn build_messages(
538        &self,
539        context_role: &'static str,
540        assistant: &str,
541        render_message: impl Fn(&super::message::MessageResponse, bool) -> serde_json::Value,
542    ) -> Vec<serde_json::Value> {
543        let mut result: Vec<serde_json::Value> = Vec::with_capacity(self.len());
544        for (tag, value) in self.build_context_messages() {
545            let value = escape_tag_value(&value);
546            result.push(serde_json::json!({
547                "role": context_role,
548                "content": format!("<{tag}>{value}</{tag}>"),
549            }));
550        }
551        for message in &self.messages {
552            let is_assistant = message.peer_id == assistant;
553            result.push(render_message(message, is_assistant));
554        }
555        result
556    }
557
558    /// Convert the context to OpenAI-compatible message format.
559    ///
560    /// System messages (`peer_representation`, `peer_card`, summary) are prepended.
561    /// Assistant messages get `role: "assistant"`, all others get `role: "user"`.
562    /// Each message also includes a `"name"` field set to the peer ID.
563    ///
564    /// `assistant` can be a `&str`, `String`, or `&Peer`.
565    ///
566    /// ```
567    /// use honcho_ai::types::session::SessionContext;
568    /// let ctx: SessionContext = serde_json::from_value(serde_json::json!({
569    ///     "id": "s1",
570    ///     "messages": [],
571    /// })).unwrap();
572    /// let messages = ctx.to_openai("assistant-1");
573    /// assert!(messages.is_empty());
574    /// ```
575    #[must_use]
576    #[allow(clippy::needless_pass_by_value)]
577    pub fn to_openai(&self, assistant: impl IntoAssistantRef) -> Vec<serde_json::Value> {
578        let assistant = assistant.as_assistant_name();
579        self.build_messages("system", assistant, |message, is_assistant| {
580            serde_json::json!({
581                "role": if is_assistant { "assistant" } else { "user" },
582                "name": message.peer_id,
583                "content": message.content,
584            })
585        })
586    }
587
588    /// Convert the context to Anthropic-compatible message format.
589    ///
590    /// System-like messages (`peer_representation`, `peer_card`, summary) use `role: "user"`
591    /// since Anthropic uses a separate `system` parameter.
592    /// Assistant messages get `role: "assistant"`, others get `role: "user"` with
593    /// `PEER_ID: CONTENT` format.
594    ///
595    /// `assistant` can be a `&str`, `String`, or `&Peer`.
596    #[must_use]
597    #[allow(clippy::needless_pass_by_value)]
598    pub fn to_anthropic(&self, assistant: impl IntoAssistantRef) -> Vec<serde_json::Value> {
599        let assistant = assistant.as_assistant_name();
600        self.build_messages("user", assistant, |message, is_assistant| {
601            if is_assistant {
602                serde_json::json!({
603                    "role": "assistant",
604                    "content": message.content,
605                })
606            } else {
607                serde_json::json!({
608                    "role": "user",
609                    "content": format!("{}: {}", message.peer_id, message.content),
610                })
611            }
612        })
613    }
614
615    /// Returns the number of entries that `to_openai` / `to_anthropic` would produce.
616    #[must_use]
617    pub fn len(&self) -> usize {
618        self.messages.len()
619            + usize::from(self.summary.is_some())
620            + usize::from(self.peer_representation.is_some())
621            + usize::from(self.peer_card.is_some())
622    }
623
624    /// Returns `true` if the context contains no messages, summary,
625    /// peer representation, or peer card.
626    #[must_use]
627    pub fn is_empty(&self) -> bool {
628        self.messages.is_empty()
629            && self.summary.is_none()
630            && self.peer_representation.is_none()
631            && self.peer_card.is_none()
632    }
633}