Skip to main content

agentyk_engine/
host.rs

1//! Per-run host context and the single event recording path.
2
3use chrono::Utc;
4
5use agentyk_core::cancellation::CancellationToken;
6use agentyk_core::driver::{ModelSpec, Usage};
7use agentyk_core::error::Result;
8use agentyk_core::event::{EventData, EventRequest, notify_event_listeners};
9use agentyk_core::event_log::{EventLog, ExpectedVersion};
10use agentyk_core::id::{EventId, SessionId, TurnId};
11use agentyk_core::input::InputQueue;
12use agentyk_core::replay::History;
13
14use crate::agent::{AgentDefinition, AgentEnvironment};
15/// The outcome of one executed turn.
16#[derive(Debug, Clone)]
17#[non_exhaustive]
18pub struct TurnResult {
19    /// Correlates with every event this turn recorded.
20    pub turn_id: TurnId,
21    /// The model's final text response.
22    pub response: String,
23    /// LLM completions performed within the turn.
24    pub iterations: usize,
25    /// Tool calls executed within the turn.
26    pub tool_calls: usize,
27    /// Tokens spent across every completion in the turn.
28    pub usage: Usage,
29}
30
31impl TurnResult {
32    /// Record how a turn ended.
33    pub fn new(
34        turn_id: TurnId,
35        response: impl Into<String>,
36        iterations: usize,
37        tool_calls: usize,
38        usage: Usage,
39    ) -> Self {
40        Self {
41            turn_id,
42            response: response.into(),
43            iterations,
44            tool_calls,
45            usage,
46        }
47    }
48}
49
50/// Agent composition plus the session-specific resources for one turn.
51#[non_exhaustive]
52pub struct TurnHost<'a> {
53    /// Session being advanced.
54    pub session_id: SessionId,
55    /// Valid behavioral definition being interpreted.
56    pub definition: &'a AgentDefinition,
57    /// Shared execution resources.
58    pub environment: &'a AgentEnvironment,
59    /// Effective model after per-turn controls.
60    pub model: &'a ModelSpec,
61    /// Durable event storage.
62    pub log: &'a dyn EventLog,
63    /// Live projection of durable message history.
64    pub history: &'a mut History,
65    /// Cooperative cancellation signal.
66    pub cancellation: CancellationToken,
67    /// Messages that arrived while this turn was running — see
68    /// [`agentyk_core::input::InputQueue`]. Empty and inert unless the caller
69    /// attached one.
70    pub input: InputQueue,
71}
72
73impl<'a> TurnHost<'a> {
74    /// Construct a host context using the agent's default model.
75    pub fn new(
76        session_id: SessionId,
77        definition: &'a AgentDefinition,
78        environment: &'a AgentEnvironment,
79        log: &'a dyn EventLog,
80        history: &'a mut History,
81    ) -> Self {
82        Self {
83            session_id,
84            definition,
85            environment,
86            model: &definition.model,
87            log,
88            history,
89            cancellation: CancellationToken::new(),
90            input: InputQueue::new(),
91        }
92    }
93
94    /// Override the effective model for this turn.
95    pub fn model(mut self, model: &'a ModelSpec) -> Self {
96        self.model = model;
97        self
98    }
99
100    /// Attach the cooperative cancellation signal.
101    pub fn cancellation(mut self, token: CancellationToken) -> Self {
102        self.cancellation = token;
103        self
104    }
105
106    /// Attach the queue a caller steers this turn through.
107    pub fn input(mut self, input: InputQueue) -> Self {
108        self.input = input;
109        self
110    }
111
112    /// Persist durable effects atomically, then advance projections and
113    /// notify observers. Ephemeral effects bypass storage.
114    pub async fn record(&mut self, turn_id: TurnId, effects: Vec<EventData>) -> Result<()> {
115        let mut durable = Vec::new();
116        let mut ephemeral = Vec::new();
117        for data in effects {
118            let request = EventRequest::with_turn(self.session_id, turn_id, data);
119            if request.data.is_ephemeral() {
120                ephemeral.push(request.into_ephemeral_event(EventId::new(), Utc::now()));
121            } else {
122                durable.push(request);
123            }
124        }
125
126        let recorded = if durable.is_empty() {
127            Vec::new()
128        } else {
129            let version = self.log.head(self.session_id).await?.sequence;
130            self.log
131                .append_batch(self.session_id, ExpectedVersion::Exact(version), durable)
132                .await?
133        };
134
135        for event in recorded.into_iter().chain(ephemeral) {
136            if event.sequence.is_some() {
137                self.history.apply(&event.data);
138            }
139            notify_event_listeners(&self.environment.listeners, &event).await;
140        }
141        Ok(())
142    }
143}
144
145#[cfg(test)]
146mod tests {
147    use std::pin::pin;
148    use std::sync::Arc;
149    use std::sync::atomic::{AtomicU32, Ordering};
150    use std::task::{Context, Poll, Waker};
151
152    use agentyk_core::context::PassthroughContextAssembler;
153    use agentyk_core::driver::{DriverRegistry, ModelSpec};
154    use agentyk_core::event::{Event, EventListener, EventRequest, event_types};
155    use agentyk_core::event_log::EventStore;
156    use agentyk_core::extensions::Extensions;
157    use agentyk_core::id::MessageId;
158
159    use super::*;
160    use crate::agent::{AgentDefinition, AgentEnvironment};
161
162    struct RejectingStore;
163
164    struct FilteredListener {
165        count: Arc<AtomicU32>,
166    }
167
168    #[async_trait::async_trait]
169    impl EventListener for FilteredListener {
170        async fn on_event(&self, _event: &Event) {
171            self.count.fetch_add(1, Ordering::SeqCst);
172        }
173
174        fn event_types(&self) -> Option<Vec<&'static str>> {
175            Some(vec![event_types::INPUT_MESSAGE])
176        }
177    }
178
179    #[async_trait::async_trait]
180    impl EventStore for RejectingStore {
181        async fn append_batch(
182            &self,
183            _session_id: SessionId,
184            _expected: ExpectedVersion,
185            _requests: Vec<EventRequest>,
186        ) -> Result<Vec<Event>> {
187            panic!("ephemeral events must not reach storage")
188        }
189
190        async fn read_after(
191            &self,
192            _session_id: SessionId,
193            _sequence: Option<u64>,
194        ) -> Result<Vec<Event>> {
195            panic!("ephemeral events must not read storage")
196        }
197    }
198
199    struct ConflictingStore;
200
201    #[async_trait::async_trait]
202    impl EventStore for ConflictingStore {
203        async fn append_batch(
204            &self,
205            _session_id: SessionId,
206            _expected: ExpectedVersion,
207            _requests: Vec<EventRequest>,
208        ) -> Result<Vec<Event>> {
209            Err(agentyk_core::error::Error::EventConflict {
210                expected: 0,
211                actual: 1,
212            })
213        }
214
215        async fn read_after(
216            &self,
217            _session_id: SessionId,
218            _sequence: Option<u64>,
219        ) -> Result<Vec<Event>> {
220            Ok(Vec::new())
221        }
222    }
223
224    fn block_on<F: std::future::Future>(future: F) -> F::Output {
225        let mut future = pin!(future);
226        let waker = Waker::noop();
227        let mut context = Context::from_waker(waker);
228        loop {
229            if let Poll::Ready(output) = future.as_mut().poll(&mut context) {
230                return output;
231            }
232        }
233    }
234
235    #[test]
236    fn ephemeral_events_bypass_the_store_entirely() {
237        let definition = AgentDefinition {
238            name: "test".into(),
239            instructions: String::new(),
240            model: ModelSpec::llmsim(),
241            capabilities: Vec::new(),
242            max_iterations: 4,
243            middleware: Vec::new(),
244            hooks: Vec::new(),
245            budget_checker: None,
246            context_assembler: Arc::new(PassthroughContextAssembler),
247            model_profile: None,
248            context_token_limit: None,
249        };
250        let environment = AgentEnvironment {
251            drivers: DriverRegistry::new(),
252            listeners: Vec::new(),
253            extensions: Extensions::new(),
254        };
255        let session_id = SessionId::new();
256        let turn_id = TurnId::new();
257        let mut history = History::new();
258        let mut host = TurnHost::new(
259            session_id,
260            &definition,
261            &environment,
262            &RejectingStore,
263            &mut history,
264        );
265
266        block_on(host.record(
267            turn_id,
268            vec![EventData::OutputMessageDelta {
269                message_id: MessageId::new(),
270                delta: "a".into(),
271                accumulated: "a".into(),
272            }],
273        ))
274        .unwrap();
275    }
276
277    #[test]
278    fn host_honors_listener_event_type_filters() {
279        let definition = AgentDefinition {
280            name: "test".into(),
281            instructions: String::new(),
282            model: ModelSpec::llmsim(),
283            capabilities: Vec::new(),
284            max_iterations: 4,
285            middleware: Vec::new(),
286            hooks: Vec::new(),
287            budget_checker: None,
288            context_assembler: Arc::new(PassthroughContextAssembler),
289            model_profile: None,
290            context_token_limit: None,
291        };
292        let count = Arc::new(AtomicU32::new(0));
293        let environment = AgentEnvironment {
294            drivers: DriverRegistry::new(),
295            listeners: vec![Arc::new(FilteredListener {
296                count: count.clone(),
297            })],
298            extensions: Extensions::new(),
299        };
300        let mut history = History::new();
301        let mut host = TurnHost::new(
302            SessionId::new(),
303            &definition,
304            &environment,
305            &RejectingStore,
306            &mut history,
307        );
308
309        block_on(host.record(
310            TurnId::new(),
311            vec![EventData::OutputMessageDelta {
312                message_id: MessageId::new(),
313                delta: "a".into(),
314                accumulated: "a".into(),
315            }],
316        ))
317        .unwrap();
318
319        assert_eq!(count.load(Ordering::SeqCst), 0);
320    }
321
322    #[test]
323    fn failed_append_does_not_advance_history() {
324        let definition = AgentDefinition {
325            name: "test".into(),
326            instructions: String::new(),
327            model: ModelSpec::llmsim(),
328            capabilities: Vec::new(),
329            max_iterations: 4,
330            middleware: Vec::new(),
331            hooks: Vec::new(),
332            budget_checker: None,
333            context_assembler: Arc::new(PassthroughContextAssembler),
334            model_profile: None,
335            context_token_limit: None,
336        };
337        let environment = AgentEnvironment {
338            drivers: DriverRegistry::new(),
339            listeners: Vec::new(),
340            extensions: Extensions::new(),
341        };
342        let session_id = SessionId::new();
343        let mut history = History::new();
344        let mut host = TurnHost::new(
345            session_id,
346            &definition,
347            &environment,
348            &ConflictingStore,
349            &mut history,
350        );
351
352        let error = block_on(host.record(
353            TurnId::new(),
354            vec![EventData::InputMessage {
355                message: agentyk_core::message::Message::user("not durable"),
356            }],
357        ))
358        .unwrap_err();
359
360        assert!(matches!(
361            error,
362            agentyk_core::error::Error::EventConflict { .. }
363        ));
364        assert!(history.messages().is_empty());
365    }
366}