Skip to main content

nanocodex_oai_api/session/
builder.rs

1use std::sync::Arc;
2
3use ::tower::Service;
4use tokio::sync::mpsc;
5
6use crate::{
7    ContentItem, EventSink, MessageRole, OpenAi, ResponseItem, ResponsesAttempt, ResponsesClient,
8    ResponsesServiceResponse, Thinking, ToolDefinition, TransportStats,
9    openai::{ResponsesServiceFactory, StandardServiceFactory},
10    responses::RequestProfile,
11};
12
13use super::{
14    context::assign_missing_response_item_id,
15    response::{
16        CompletedCompaction, Response, ResponseError, ResponseInput, run_compact, run_create,
17    },
18    state::{ManagedSessionState, SessionId},
19};
20
21const RESPONSE_EVENT_CAPACITY: usize = 64;
22
23/// Builder for one instruction-bound managed Responses session.
24#[derive(Clone)]
25pub struct SessionBuilder<F = StandardServiceFactory> {
26    openai: OpenAi<F>,
27    instructions: Arc<str>,
28    session_id: Option<SessionId>,
29    prompt_cache_key: Option<String>,
30    tools: Vec<ToolDefinition>,
31}
32
33impl<F> SessionBuilder<F>
34where
35    F: ResponsesServiceFactory,
36{
37    pub(crate) const fn new(openai: OpenAi<F>, instructions: Arc<str>) -> Self {
38        Self {
39            openai,
40            instructions,
41            session_id: None,
42            prompt_cache_key: None,
43            tools: Vec::new(),
44        }
45    }
46
47    /// Sets the client-side session identity used for tracing and cache
48    /// lineage.
49    #[must_use]
50    pub const fn session_id(mut self, session_id: SessionId) -> Self {
51        self.session_id = Some(session_id);
52        self
53    }
54
55    /// Sets the stable cache key for the immutable instructions and tools.
56    #[must_use]
57    pub fn prompt_cache_key(mut self, prompt_cache_key: impl Into<String>) -> Self {
58        self.prompt_cache_key = Some(prompt_cache_key.into());
59        self
60    }
61
62    /// Installs the complete tool definitions sent to the model.
63    ///
64    /// This is the protocol-level API for callers that execute tool calls
65    /// themselves. [`nanocodex-tools`](https://docs.rs/nanocodex-tools)
66    /// provides a registry and concrete runtimes for applications that want
67    /// Nanocodex to dispatch the calls.
68    ///
69    /// ```
70    /// use nanocodex_oai_api::{
71    ///     OpenAi,
72    ///     responses::JsonSchema,
73    ///     tools::ToolDefinition,
74    /// };
75    /// use serde_json::json;
76    ///
77    /// let openai = OpenAi::new("test-api-key")?;
78    /// let session = openai
79    ///     .instructions(
80    ///         "Use lookup_region for deployment questions. Preserve exact identifiers.",
81    ///     )
82    ///     .tool_definitions([ToolDefinition::function(
83    ///         "lookup_region",
84    ///         "Return deployment metadata for one exact region identifier.",
85    ///         JsonSchema::from(json!({
86    ///             "type": "object",
87    ///             "properties": {
88    ///                 "region": { "type": "string" }
89    ///             },
90    ///             "required": ["region"],
91    ///             "additionalProperties": false
92    ///         })),
93    ///     )])
94    ///     .build()?;
95    ///
96    /// assert_eq!(session.history_len(), 0);
97    /// # Ok::<(), Box<dyn std::error::Error>>(())
98    /// ```
99    #[must_use]
100    pub fn tool_definitions(mut self, tools: impl IntoIterator<Item = ToolDefinition>) -> Self {
101        self.tools = tools.into_iter().collect();
102        self
103    }
104
105    /// Creates fresh service, context, and continuation state.
106    ///
107    /// # Errors
108    ///
109    /// Returns an error when instructions or the cache identity are empty.
110    pub fn build(self) -> Result<Session<F::Service>, SessionBuildError> {
111        if self.instructions.trim().is_empty() {
112            return Err(SessionBuildError::EmptyInstructions);
113        }
114        let session_id = self.session_id.unwrap_or_default();
115        let prompt_cache_key = self
116            .prompt_cache_key
117            .unwrap_or_else(|| session_id.to_string());
118        if prompt_cache_key.trim().is_empty() {
119            return Err(SessionBuildError::EmptyPromptCacheKey);
120        }
121
122        let mut prefix = [
123            ResponseItem::additional_tools(self.tools),
124            ResponseItem::message(
125                MessageRole::Developer,
126                [ContentItem::InputText {
127                    text: self.instructions.to_string().into_boxed_str(),
128                }],
129            ),
130        ];
131        assign_request_prefix_ids(&mut prefix);
132        let profile =
133            RequestProfile::new(session_id.to_string(), prompt_cache_key, Arc::from(prefix));
134        let config = self.openai.config().clone();
135        let service = self.openai.make_service();
136
137        Ok(Session {
138            id: session_id,
139            client: ResponsesClient::new(service),
140            profile,
141            state: ManagedSessionState::new(Vec::new()),
142            canonical_context: Vec::new(),
143            canonical_context_reinjection_pending: false,
144            next_call_index: 1,
145            next_logical_turn: 1,
146            thinking: config.thinking,
147            fast_mode: config.fast_mode,
148            transport_stats: Arc::new(TransportStats::default()),
149        })
150    }
151}
152
153/// Invalid managed-session construction.
154#[derive(Debug, thiserror::Error)]
155pub enum SessionBuildError {
156    /// Stable developer instructions were empty.
157    #[error("OpenAI session instructions must not be empty")]
158    EmptyInstructions,
159    /// The explicit prompt-cache identity was empty.
160    #[error("OpenAI prompt cache key must not be empty")]
161    EmptyPromptCacheKey,
162}
163
164/// One managed `OpenAI` Responses conversation.
165///
166/// The session owns its concrete Tower service, persistent transport,
167/// authoritative typed history, usage, and private continuation state.
168pub struct Session<S> {
169    pub(super) id: SessionId,
170    pub(super) client: ResponsesClient<S>,
171    pub(super) profile: RequestProfile,
172    pub(super) state: ManagedSessionState,
173    pub(super) canonical_context: Vec<ResponseItem>,
174    pub(super) canonical_context_reinjection_pending: bool,
175    pub(super) next_call_index: u32,
176    next_logical_turn: u64,
177    pub(super) thinking: Thinking,
178    pub(super) fast_mode: bool,
179    pub(super) transport_stats: Arc<TransportStats>,
180}
181
182impl<S> Session<S> {
183    /// Returns the client-side session identity.
184    #[must_use]
185    pub const fn id(&self) -> SessionId {
186        self.id
187    }
188
189    /// Starts one logical agent turn.
190    ///
191    /// Every `create` and `compact` call made through the returned value shares
192    /// turn-scoped protocol state. A compaction before the first completed
193    /// `create` is pre-turn; a compaction after one is mid-turn. Dropping the
194    /// value ends the boundary.
195    pub const fn turn(&mut self) -> ResponseTurn<'_, S> {
196        let logical_turn = self.next_logical_turn;
197        self.next_logical_turn = self.next_logical_turn.saturating_add(1);
198        ResponseTurn {
199            session: self,
200            logical_turn,
201            completed_generation: false,
202        }
203    }
204
205    /// Returns the number of committed typed history items.
206    #[must_use]
207    pub fn history_len(&self) -> usize {
208        self.state.history_len()
209    }
210
211    /// Iterates over committed authoritative history without exposing mutable
212    /// access.
213    pub fn history(&self) -> impl ExactSizeIterator<Item = &ResponseItem> {
214        self.state.history()
215    }
216
217    /// Returns the best available estimate of tokens in the active provider
218    /// context.
219    ///
220    /// The session combines completed provider usage with locally appended
221    /// items and retained reasoning according to the transport metadata it has
222    /// observed. Higher-level agents use this summary to decide *when* to call
223    /// [`ResponseTurn::compact`].
224    #[must_use]
225    pub fn active_context_tokens(&self) -> u64 {
226        self.state.active_context_tokens()
227    }
228}
229
230/// Turn-scoped Responses operations borrowing one managed session.
231pub struct ResponseTurn<'session, S> {
232    pub(super) session: &'session mut Session<S>,
233    pub(super) logical_turn: u64,
234    pub(super) completed_generation: bool,
235}
236
237impl<S> ResponseTurn<'_, S> {
238    /// Returns the session's best available active-context token estimate.
239    #[must_use]
240    pub fn active_context_tokens(&self) -> u64 {
241        self.session.active_context_tokens()
242    }
243}
244
245#[cfg(not(target_family = "wasm"))]
246impl<S> ResponseTurn<'_, S>
247where
248    S: Service<ResponsesAttempt, Response = ResponsesServiceResponse> + Send,
249    S::Error: Into<ResponseError> + Send,
250    S::Future: Send,
251{
252    /// Starts one streamed `response.create` operation.
253    pub fn create(&mut self, input: impl Into<ResponseInput>) -> Response<'_> {
254        let (sink, raw_events) = EventSink::channel(self.session.profile.session_id().to_owned());
255        drop(raw_events);
256        let (response_events, events) = mpsc::channel(RESPONSE_EVENT_CAPACITY);
257        let run = Box::pin(run_create(self, input.into(), sink, response_events));
258        Response::new(events, run)
259    }
260
261    /// Executes `response.compact` and atomically installs its completed
262    /// history replacement.
263    ///
264    /// Pre-turn compaction defers the session's last caller-supplied developer,
265    /// `AGENTS.md`, and environment-context snapshot until the next normal
266    /// `create`. Mid-turn compaction installs that snapshot immediately at the
267    /// model-trained boundary before the last real user message. The standalone
268    /// session never reads the filesystem to refresh this fallback.
269    ///
270    /// # Errors
271    ///
272    /// Returns a typed transport, protocol, or context error. Failed
273    /// compaction leaves the prior history untouched.
274    pub async fn compact(&mut self) -> Result<CompletedCompaction, ResponseError> {
275        run_compact(self).await
276    }
277}
278
279#[cfg(target_family = "wasm")]
280impl<S> ResponseTurn<'_, S>
281where
282    S: Service<ResponsesAttempt, Response = ResponsesServiceResponse>,
283    S::Error: Into<ResponseError>,
284{
285    /// Starts one streamed `response.create` operation.
286    pub fn create(&mut self, input: impl Into<ResponseInput>) -> Response<'_> {
287        let (sink, raw_events) = EventSink::channel(self.session.profile.session_id().to_owned());
288        drop(raw_events);
289        let (response_events, events) = mpsc::channel(RESPONSE_EVENT_CAPACITY);
290        let run = Box::pin(run_create(self, input.into(), sink, response_events));
291        Response::new(events, run)
292    }
293
294    /// Executes `response.compact` and atomically installs its completed
295    /// history replacement.
296    ///
297    /// Pre-turn compaction defers the session's last caller-supplied developer,
298    /// `AGENTS.md`, and environment-context snapshot until the next normal
299    /// `create`. Mid-turn compaction installs that snapshot immediately at the
300    /// model-trained boundary before the last real user message. The standalone
301    /// session never reads the filesystem to refresh this fallback.
302    ///
303    /// # Errors
304    ///
305    /// Returns a typed transport, protocol, or context error. Failed
306    /// compaction leaves the prior history untouched.
307    pub async fn compact(&mut self) -> Result<CompletedCompaction, ResponseError> {
308        run_compact(self).await
309    }
310}
311
312fn assign_request_prefix_ids(prefix: &mut [ResponseItem]) {
313    for item in prefix {
314        if matches!(
315            item,
316            ResponseItem::AdditionalTools { .. }
317                | ResponseItem::Message {
318                    role: MessageRole::Developer,
319                    ..
320                }
321        ) {
322            item.strip_id();
323            continue;
324        }
325        assign_missing_response_item_id(item);
326    }
327}