Skip to main content

locode_engine/
session.rs

1//! The public driving API.
2
3use std::sync::Arc;
4
5use locode_protocol::{ContentBlock, Event, Message, Report, Role};
6use locode_provider::Provider;
7use locode_tools::Registry;
8use tokio_util::sync::CancellationToken;
9
10use crate::approve::{AllowAll, Approver};
11use crate::config::EngineConfig;
12use crate::sink::EventSink;
13
14/// One driven agent session. Owns the conversation history **across runs**: a
15/// second [`Session::run`] on the same session continues the same conversation
16/// (ADR-0016) — the exact call shape an interactive frontend needs for
17/// follow-up turns.
18///
19/// Construct with [`Session::new`], then call [`Session::run`] (or
20/// [`Session::run_text`]) to drive one run to a terminal state. `run` is
21/// **infallible** — every terminal condition (including provider and `Fatal` tool
22/// errors) is captured in the returned [`Report`]'s `status`/`error`, so a caller
23/// gets a structured result every time (`locode-exec` maps status → exit code).
24///
25/// Each [`Report`] is **per-run**: `turns`/`usage`/`tool_calls` count the current
26/// run only (a cumulative view is derivable from the event stream). Continuing
27/// after a failed run is allowed unconditionally — for `ModelError` the history
28/// simply didn't advance, and for `Error` the transcript was fully paired before
29/// the break; the pre-send pairing repair heals any residue on the next sample.
30pub struct Session {
31    pub(crate) provider: Arc<dyn Provider>,
32    pub(crate) registry: Registry,
33    pub(crate) preamble: Vec<Message>,
34    pub(crate) config: EngineConfig,
35    pub(crate) sink: Box<dyn EventSink>,
36    pub(crate) cancel: CancellationToken,
37    /// The conversation so far: preamble + every appended turn, across runs.
38    pub(crate) history: Vec<Message>,
39    /// Runs driven on this session; gates the once-per-session `Init` event.
40    pub(crate) turns_run: u32,
41    /// The pre-dispatch approval gate (ADR-0017); [`AllowAll`] by default.
42    pub(crate) approver: Arc<dyn Approver>,
43    /// The listing body from the most recent scan, injected on the next turn
44    /// (ADR-0025 §3.2 — scanning happens after a run, never at the top of one).
45    pub(crate) skills_body: Option<String>,
46}
47
48impl Session {
49    /// Assemble a session from its parts.
50    ///
51    /// `preamble` is the base `System` + `Developer` messages (the pack supplies
52    /// these); `provider`/`sink` are trait objects so the binary can select them at
53    /// runtime.
54    #[must_use]
55    pub fn new(
56        provider: Arc<dyn Provider>,
57        registry: Registry,
58        preamble: Vec<Message>,
59        config: EngineConfig,
60        sink: Box<dyn EventSink>,
61    ) -> Self {
62        Self {
63            provider,
64            registry,
65            history: preamble.clone(),
66            preamble,
67            config,
68            sink,
69            cancel: CancellationToken::new(),
70            turns_run: 0,
71            approver: Arc::new(AllowAll),
72            skills_body: None,
73        }
74    }
75
76    /// Install an [`Approver`] consulted before every tool call (ADR-0017).
77    ///
78    /// Builder-style so [`Session::new`]'s signature stays intact. The default
79    /// is [`AllowAll`] — headless consumers are unchanged without this call.
80    #[must_use]
81    pub fn with_approver(mut self, approver: Arc<dyn Approver>) -> Self {
82        self.approver = approver;
83        self
84    }
85
86    /// Switch the model this session samples with, mid-conversation.
87    ///
88    /// The caller rebuilds the provider (the registry's factory already takes a model
89    /// override) and hands it in; this swaps it and updates the config so the trace's
90    /// later records name the model actually in use.
91    ///
92    /// **The preamble is not rewritten.** A pack's system prompt may name the model —
93    /// Claude Code's env block does, and so does our port — and after a switch that line
94    /// is stale. Rewriting the `System` message would desync the transcript from the
95    /// trace, whose `Init` record already captured the original preamble: a resumed
96    /// session would replay one preamble while the live session had another. So the
97    /// change is **announced instead**, as an appended `<system-reminder>` — the same
98    /// never-mutate-history discipline project instructions and skills already follow.
99    ///
100    /// Returns the announcement, which the caller appends and emits like any other
101    /// message. (Doing it here would bypass the sink the caller owns.)
102    pub fn set_model(&mut self, provider: Arc<dyn Provider>, model: &str) -> Message {
103        self.provider = provider;
104        self.config.model = model.to_string();
105        Message {
106            role: Role::User,
107            content: vec![ContentBlock::Text {
108                text: format!(
109                    "<system-reminder>\nThe model for this conversation is now {model}. \
110                     Any earlier statement about which model you are is out of date.\n\
111                     </system-reminder>"
112                ),
113            }],
114        }
115    }
116
117    /// Append a message to the conversation and emit it, exactly as a turn would.
118    pub fn announce(&mut self, message: Message) {
119        self.history.push(message.clone());
120        self.sink.emit(Event::Message { message });
121    }
122
123    /// The conversation so far: the preamble plus every appended turn across all
124    /// runs on this session (ADR-0016). Lets a frontend render the transcript
125    /// after a run without replaying the event stream.
126    #[must_use]
127    pub fn history(&self) -> &[Message] {
128        &self.history
129    }
130
131    /// The cancellation handle for the **current run** (ADR-0018).
132    ///
133    /// Clone it *before* calling [`Session::run`] (mandatory — `run` takes
134    /// `&mut self`, so nothing is callable mid-run) and move it into an Esc
135    /// handler, signal handler, or timeout. Firing it stops the run at the
136    /// next observation point — mid-sample (the in-flight request is
137    /// aborted), between batch calls (the rest of the batch is paired
138    /// synthetically), or at the loop top — and the run returns a report with
139    /// [`Status::Cancelled`](locode_protocol::Status). Partial work is
140    /// preserved: with session continuity, the next `run()` continues the
141    /// same conversation.
142    ///
143    /// The token is **per-run, replaced when `run` returns**: a cancel landing
144    /// after the run ended hits the retired token — a harmless no-op — so the
145    /// Esc-lands-late race is resolved by construction. Re-fetch the handle
146    /// each turn. `cancel()` is idempotent; there is no reset.
147    #[must_use]
148    pub fn cancel_handle(&self) -> CancellationToken {
149        self.cancel.clone()
150    }
151
152    /// Drive the loop to a terminal state and return the run's [`Report`].
153    pub async fn run(&mut self, user: Vec<ContentBlock>) -> Report {
154        self.drive(user).await
155    }
156
157    /// Convenience: drive with a plain-text user prompt.
158    pub async fn run_text(&mut self, prompt: impl Into<String>) -> Report {
159        self.run(vec![ContentBlock::Text {
160            text: prompt.into(),
161        }])
162        .await
163    }
164}