Skip to main content

agentyk_engine/
session.rs

1//! Session — a conversation with an agent.
2//!
3//! `Session::run` executes one turn of the everruns `input → reason → act`
4//! contract through the canonical engine and its in-process host. Every step
5//! is appended to the event log
6//! and fanned out to listeners; a session can be
7//! [resumed](crate::Agent::resume_session) from its log alone.
8
9use std::sync::Arc;
10
11use agentyk_core::cancellation::CancellationToken;
12use agentyk_core::capability::{CommandContext, CommandDescriptor};
13use agentyk_core::controls::TurnControls;
14use agentyk_core::error::Result;
15use agentyk_core::event::{Event, EventData, EventRequest, notify_event_listeners};
16use agentyk_core::event_log::{EventLog, ExpectedVersion, SessionPoint};
17use agentyk_core::hook::{HookContext, HookEvent};
18use agentyk_core::id::SessionId;
19use agentyk_core::input::InputQueue;
20use agentyk_core::message::Message;
21use agentyk_core::replay::History;
22use agentyk_core::tool::ToolOutput;
23use agentyk_core::turn::TurnState;
24
25use crate::agent::Agent;
26use crate::hooks;
27use crate::host::{TurnHost, TurnResult};
28use crate::in_process::InProcessExecutor;
29
30/// Everything one [`Session::run_with_options`] call can override. Defaults
31/// (`RunOptions::default()`) reproduce plain [`Session::run`]: an
32/// uncancellable turn on the agent's default model.
33#[derive(Default)]
34#[non_exhaustive]
35pub struct RunOptions {
36    /// Clone this before calling and cancel the clone from elsewhere to
37    /// stop the turn — see [`CancellationToken`].
38    pub cancellation: CancellationToken,
39    /// Per-turn model/reasoning overrides — see [`TurnControls`].
40    pub controls: TurnControls,
41}
42
43impl RunOptions {
44    /// Defaults: uncancellable, on the agent's own model.
45    pub fn new() -> Self {
46        Self::default()
47    }
48
49    /// Stop the turn from elsewhere — see [`CancellationToken`].
50    pub fn cancellation(mut self, token: CancellationToken) -> Self {
51        self.cancellation = token;
52        self
53    }
54
55    /// Per-turn model/reasoning overrides — see [`TurnControls`].
56    pub fn controls(mut self, controls: TurnControls) -> Self {
57        self.controls = controls;
58        self
59    }
60}
61
62/// A conversation with an agent.
63///
64/// Holds the session id, the event log, and the message history — which is a
65/// projection of that log, never a separate source. Sessions are created from
66/// an [`Agent`] and can be resumed from a log alone.
67pub struct Session {
68    agent: Agent,
69    id: SessionId,
70    log: Arc<dyn EventLog>,
71    history: History,
72    input: InputQueue,
73    started: bool,
74    closed: bool,
75}
76
77/// Read-only projection of a session at an immutable historical point.
78#[derive(Debug, Clone)]
79pub struct SessionView {
80    point: SessionPoint,
81    events: Vec<Event>,
82    history: History,
83}
84
85impl SessionView {
86    /// Exact event position this view includes.
87    pub fn point(&self) -> SessionPoint {
88        self.point
89    }
90
91    /// Durable events through [`Self::point`], in sequence order.
92    pub fn events(&self) -> &[Event] {
93        &self.events
94    }
95
96    /// Message projection at this point.
97    pub fn messages(&self) -> &[Message] {
98        self.history.messages()
99    }
100}
101
102impl Session {
103    pub(crate) fn new(agent: Agent, log: Arc<dyn EventLog>) -> Self {
104        Self {
105            agent,
106            id: SessionId::new(),
107            log,
108            history: History::new(),
109            input: InputQueue::new(),
110            started: false,
111            closed: false,
112        }
113    }
114
115    pub(crate) async fn resume(
116        agent: Agent,
117        log: Arc<dyn EventLog>,
118        session_id: SessionId,
119    ) -> Result<Self> {
120        let events = log.read(session_id).await?;
121        let started = !events.is_empty();
122        Ok(Self {
123            agent,
124            id: session_id,
125            log,
126            history: History::from_events(&events),
127            input: InputQueue::new(),
128            // A non-empty log proves the first turn boundary was crossed.
129            // An empty recovered session has no durable evidence that its
130            // start hook ran, so it follows the ordinary first-turn path.
131            started,
132            closed: false,
133        })
134    }
135
136    /// The session's correlation id — generated internally, readable on
137    /// demand, never an input you must mint.
138    pub fn id(&self) -> SessionId {
139        self.id
140    }
141
142    /// A handle for steering a turn that is already running.
143    ///
144    /// `run` borrows the session for the whole turn, so this is taken
145    /// *before* starting one and used from wherever the input comes from — a
146    /// UI task, a socket, a signal handler. Messages join the conversation at
147    /// the turn's next reasoning step; see
148    /// [`agentyk_core::input::InputQueue::drain`] for why that boundary.
149    ///
150    /// Anything pushed while no turn is running waits for the next one, so a
151    /// UI never has to know whether the agent is busy.
152    ///
153    /// ```
154    /// use agentyk_core::{input::InputQueue, message::Message};
155    ///
156    /// // What `session.input()` hands out: a queue whose clones share one
157    /// // backlog, so a UI task can push while the turn runs.
158    /// let steering = InputQueue::new();
159    /// let from_the_ui = steering.clone();
160    /// from_the_ui.push(Message::user("skip the tests, just build"));
161    ///
162    /// assert_eq!(steering.drain().len(), 1);
163    /// ```
164    pub fn input(&self) -> InputQueue {
165        self.input.clone()
166    }
167
168    /// Whether [`Session::close`] has fired this session's end hooks.
169    pub fn is_closed(&self) -> bool {
170        self.closed
171    }
172
173    /// Close this in-memory session and fire advisory `session_end` hooks.
174    ///
175    /// Dropping a Rust value cannot await async hooks, so session end is
176    /// explicit. Closing is idempotent. A closed session retains its history
177    /// for inspection but refuses new turns.
178    pub async fn close(&mut self) -> Result<()> {
179        if self.closed {
180            return Ok(());
181        }
182        let warnings = hooks::run_advisory_hooks(
183            &self.agent.definition().hooks,
184            HookEvent::SessionEnd,
185            HookContext::session(self.id),
186            serde_json::json!({ "reason": "closed" }),
187        )
188        .await;
189        self.record_session_events(warnings).await?;
190        self.closed = true;
191        Ok(())
192    }
193
194    /// Current immutable point at the session's durable head.
195    pub async fn point(&self) -> Result<SessionPoint> {
196        self.log.head(self.id).await
197    }
198
199    /// Reconstruct a read-only view without executing or modifying history.
200    pub async fn inspect(&self, point: SessionPoint) -> Result<SessionView> {
201        if point.session_id != self.id {
202            return Err(agentyk_core::error::Error::EventLog(format!(
203                "point belongs to {}, not session {}",
204                point.session_id, self.id
205            )));
206        }
207        let head = self.log.head(self.id).await?;
208        if point.sequence > head.sequence {
209            return Err(agentyk_core::error::Error::EventLog(format!(
210                "point {} is past session head {}",
211                point.sequence, head.sequence
212            )));
213        }
214        let events = self.log.read_through(point).await?;
215        Ok(SessionView {
216            point,
217            history: History::from_events(&events),
218            events,
219        })
220    }
221
222    /// Create an independent branch from a completed-turn boundary.
223    ///
224    /// The original session remains unchanged. The returned session inherits
225    /// history through `point` and records `session.forked` lineage before new
226    /// turns are appended.
227    pub async fn fork(&self, point: SessionPoint) -> Result<Self> {
228        let view = self.inspect(point).await?;
229        if !is_completed_turn_boundary(view.events()) {
230            return Err(agentyk_core::error::Error::EventLog(
231                "sessions can only fork from an empty stream or a completed-turn boundary".into(),
232            ));
233        }
234        let child_id = self.log.fork(point).await?;
235        let mut child = Self::resume(self.agent.clone(), self.log.clone(), child_id).await?;
236        child.started = false;
237        Ok(child)
238    }
239
240    /// Continue the newest incomplete turn at this session's current head.
241    ///
242    /// Returns `None` when the session has no incomplete turn. Recovery is
243    /// at-least-once for external actions; see
244    /// [`InProcessExecutor::resume_turn`]. The agent's default model is used.
245    pub async fn resume_pending(&mut self) -> Result<Option<TurnResult>> {
246        self.resume_pending_with_options(RunOptions::default())
247            .await
248    }
249
250    /// Continue the newest incomplete turn with explicit cancellation and
251    /// model controls.
252    pub async fn resume_pending_with_options(
253        &mut self,
254        options: RunOptions,
255    ) -> Result<Option<TurnResult>> {
256        let agent = self.agent.clone();
257        self.resume_pending_with_agent_and_options(&agent, options)
258            .await
259    }
260
261    /// Continue an incomplete turn using the agent that originally answered
262    /// it in a multi-agent session.
263    ///
264    /// The event log records turn state, not sensitive agent configuration.
265    /// A host therefore supplies the same by-value agent again when recovering
266    /// an addressed guest turn.
267    pub async fn resume_pending_with_agent(&mut self, agent: &Agent) -> Result<Option<TurnResult>> {
268        self.resume_pending_with_agent_and_options(agent, RunOptions::default())
269            .await
270    }
271
272    /// Continue an incomplete guest-agent turn with explicit run options.
273    pub async fn resume_pending_with_agent_and_options(
274        &mut self,
275        agent: &Agent,
276        options: RunOptions,
277    ) -> Result<Option<TurnResult>> {
278        let events = self.log.read(self.id).await?;
279        let Some(turn_id) = events.iter().rev().find_map(|event| {
280            matches!(event.data, EventData::TurnStarted { .. })
281                .then_some(event.turn_id)
282                .flatten()
283        }) else {
284            return Ok(None);
285        };
286        let state = TurnState::replay(&events, turn_id)?;
287        if state.is_complete() {
288            return Ok(None);
289        }
290
291        let definition = agent.definition();
292        let effective_model = options.controls.resolve(&definition.model);
293        let mut host = TurnHost::new(
294            self.id,
295            definition,
296            self.agent.environment(),
297            self.log.as_ref(),
298            &mut self.history,
299        )
300        .model(&effective_model)
301        .cancellation(options.cancellation);
302        InProcessExecutor::new()
303            .resume_turn(&mut host, state)
304            .await
305            .map(Some)
306    }
307
308    /// The reconstructed-or-live message history — always equal to a replay
309    /// of this session's log.
310    pub fn messages(&self) -> &[Message] {
311        self.history.messages()
312    }
313
314    /// All events recorded for this session, ordered by sequence.
315    pub async fn events(&self) -> Result<Vec<Event>> {
316        self.log.read(self.id).await
317    }
318
319    /// Every slash command the agent's capabilities expose — see
320    /// [`agentyk_core::capability::Capability::commands`].
321    pub fn commands(&self) -> Vec<CommandDescriptor> {
322        self.agent
323            .definition()
324            .capabilities
325            .iter()
326            .flat_map(|capability| capability.commands())
327            .collect()
328    }
329
330    /// Run a command directly — bypasses the turn loop entirely (no model
331    /// call, no event log entry): the first capability whose
332    /// [`agentyk_core::capability::Capability::commands`] lists `name` and
333    /// whose `execute_command` returns `Some(..)` wins. Errors with an
334    /// "unknown command" message if none claims it.
335    pub async fn execute_command(&self, name: &str, args: &str) -> Result<ToolOutput> {
336        let context = CommandContext::new(self.id);
337        for capability in &self.agent.definition().capabilities {
338            if capability.commands().iter().any(|c| c.name == name)
339                && let Some(output) = capability.execute_command(name, args, &context).await
340            {
341                return Ok(output);
342            }
343        }
344        Ok(ToolOutput::error(format!("unknown command `/{name}`")))
345    }
346
347    /// Run one turn: user input in, final assistant text out. Uncancellable
348    /// and on the agent's default model — see [`Session::run_with_options`]
349    /// for cancellation and per-turn model/reasoning overrides.
350    ///
351    /// Takes anything that becomes a [`Message`], so `run("hello")` stays a
352    /// one-liner while a turn opened with an image is
353    /// `run(Message::user_multimodal(parts))` rather than a different method.
354    pub async fn run(&mut self, input: impl Into<Message>) -> Result<TurnResult> {
355        self.run_with_options(input, RunOptions::default()).await
356    }
357
358    /// Run one turn, stoppable from elsewhere: clone `token` before calling,
359    /// keep the clone, and call [`CancellationToken::cancel`] on it (e.g.
360    /// from a UI event or another task) to stop the turn at its next check
361    /// point — between reason/tool steps, or between streaming chunks.
362    /// Returns `Err(Error::Cancelled)` if the turn was stopped this way.
363    pub async fn run_cancellable(
364        &mut self,
365        input: impl Into<Message>,
366        token: CancellationToken,
367    ) -> Result<TurnResult> {
368        self.run_with_options(input, RunOptions::new().cancellation(token))
369            .await
370    }
371
372    /// Run one turn with per-turn model/reasoning overrides, without
373    /// rebuilding the agent — see [`TurnControls`].
374    pub async fn run_controlled(
375        &mut self,
376        input: impl Into<Message>,
377        controls: TurnControls,
378    ) -> Result<TurnResult> {
379        self.run_with_options(input, RunOptions::new().controls(controls))
380            .await
381    }
382
383    /// Run one turn with full control over cancellation and per-turn
384    /// overrides — the general form `run`/`run_cancellable`/`run_controlled`
385    /// delegate to.
386    pub async fn run_with_options(
387        &mut self,
388        input: impl Into<Message>,
389        options: RunOptions,
390    ) -> Result<TurnResult> {
391        let agent = self.agent.clone();
392        self.run_with_agent_and_options(&agent, input, options)
393            .await
394    }
395
396    /// Run an addressed turn with another by-value agent definition while
397    /// retaining this session's shared history and host environment.
398    ///
399    /// This is the engine seam behind multi-agent session routing. The
400    /// embedding host owns membership, roles, and authorization, resolves the
401    /// addressed participant to an [`Agent`], then passes that value here. The
402    /// guest contributes behavior and model defaults; the session host keeps
403    /// supplying drivers, listeners, and extensions. Plain [`Self::run`]
404    /// continues to route to the session's host agent.
405    pub async fn run_with_agent(
406        &mut self,
407        agent: &Agent,
408        input: impl Into<Message>,
409    ) -> Result<TurnResult> {
410        self.run_with_agent_and_options(agent, input, RunOptions::default())
411            .await
412    }
413
414    /// Run an addressed agent turn with cancellation and model overrides.
415    pub async fn run_with_agent_and_options(
416        &mut self,
417        agent: &Agent,
418        input: impl Into<Message>,
419        options: RunOptions,
420    ) -> Result<TurnResult> {
421        if self.closed {
422            return Err(agentyk_core::error::Error::Other(
423                "session is closed".into(),
424            ));
425        }
426        if !self.started {
427            let warnings = hooks::run_advisory_hooks(
428                &self.agent.definition().hooks,
429                HookEvent::SessionStart,
430                HookContext::session(self.id),
431                serde_json::json!({ "agent": self.agent.name() }),
432            )
433            .await;
434            self.record_session_events(warnings).await?;
435            self.started = true;
436        }
437        let definition = agent.definition();
438        let effective_model = options.controls.resolve(&definition.model);
439        let mut host = TurnHost::new(
440            self.id,
441            definition,
442            self.agent.environment(),
443            self.log.as_ref(),
444            &mut self.history,
445        )
446        .model(&effective_model)
447        .cancellation(options.cancellation)
448        .input(self.input.clone());
449        InProcessExecutor::new()
450            .run_turn(&mut host, input.into())
451            .await
452    }
453
454    async fn record_session_events(&mut self, events: Vec<EventData>) -> Result<()> {
455        if events.is_empty() {
456            return Ok(());
457        }
458        let requests = events
459            .into_iter()
460            .map(|data| EventRequest::new(self.id, data))
461            .collect();
462        let version = self.log.head(self.id).await?.sequence;
463        let recorded = self
464            .log
465            .append_batch(self.id, ExpectedVersion::Exact(version), requests)
466            .await?;
467        for event in recorded {
468            self.history.apply(&event.data);
469            notify_event_listeners(&self.agent.environment().listeners, &event).await;
470        }
471        Ok(())
472    }
473}
474
475fn is_completed_turn_boundary(events: &[Event]) -> bool {
476    let Some(turn_id) = events.iter().rev().find_map(|event| event.turn_id) else {
477        return true;
478    };
479    events.iter().any(|event| {
480        event.turn_id == Some(turn_id)
481            && matches!(
482                event.data,
483                EventData::TurnCompleted { .. }
484                    | EventData::TurnFailed { .. }
485                    | EventData::TurnCancelled
486                    | EventData::TurnSealed { .. }
487            )
488    })
489}