locode_engine/session.rs
1//! The public driving API.
2
3use std::sync::Arc;
4
5use locode_protocol::{ContentBlock, Message, Report};
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 /// Hash of the currently-applied project instructions (ADR-0023 per-turn rescan):
44 /// `None` = none apply, `Some(h)` = these apply. Drives the replace/remove banner.
45 pub(crate) last_instructions: Option<u64>,
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 last_instructions: 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 /// The conversation so far: the preamble plus every appended turn across all
87 /// runs on this session (ADR-0016). Lets a frontend render the transcript
88 /// after a run without replaying the event stream.
89 #[must_use]
90 pub fn history(&self) -> &[Message] {
91 &self.history
92 }
93
94 /// The cancellation handle for the **current run** (ADR-0018).
95 ///
96 /// Clone it *before* calling [`Session::run`] (mandatory — `run` takes
97 /// `&mut self`, so nothing is callable mid-run) and move it into an Esc
98 /// handler, signal handler, or timeout. Firing it stops the run at the
99 /// next observation point — mid-sample (the in-flight request is
100 /// aborted), between batch calls (the rest of the batch is paired
101 /// synthetically), or at the loop top — and the run returns a report with
102 /// [`Status::Cancelled`](locode_protocol::Status). Partial work is
103 /// preserved: with session continuity, the next `run()` continues the
104 /// same conversation.
105 ///
106 /// The token is **per-run, replaced when `run` returns**: a cancel landing
107 /// after the run ended hits the retired token — a harmless no-op — so the
108 /// Esc-lands-late race is resolved by construction. Re-fetch the handle
109 /// each turn. `cancel()` is idempotent; there is no reset.
110 #[must_use]
111 pub fn cancel_handle(&self) -> CancellationToken {
112 self.cancel.clone()
113 }
114
115 /// Drive the loop to a terminal state and return the run's [`Report`].
116 pub async fn run(&mut self, user: Vec<ContentBlock>) -> Report {
117 self.drive(user).await
118 }
119
120 /// Convenience: drive with a plain-text user prompt.
121 pub async fn run_text(&mut self, prompt: impl Into<String>) -> Report {
122 self.run(vec![ContentBlock::Text {
123 text: prompt.into(),
124 }])
125 .await
126 }
127}