Skip to main content

agentyk_core/
context.rs

1//! The context-assembly seam — what actually gets sent to the model, as
2//! opposed to what's in the log.
3//!
4//! By default a turn sends its *entire* replayed history to the model every
5//! time. That's correct and simple, but a long-running session eventually
6//! wants something smarter — trimming, summarization/compaction, memory
7//! injection. [`ContextAssembler`] is where that lives: it sits between
8//! replay and [`crate::atoms::reason`], transforming the full history into
9//! whatever this turn actually sends. [`PassthroughContextAssembler`] (the
10//! default — see `AgentBuilder::context_assembler` in the framework crate)
11//! does nothing; compaction and friends are meant to be *implementations*
12//! of this trait, not changes to the turn machine.
13
14use async_trait::async_trait;
15use serde::{Deserialize, Serialize};
16
17use crate::driver::ModelSpec;
18use crate::error::Result;
19use crate::event::EventData;
20use crate::event_log::{EventStore, SessionPoint};
21use crate::id::TurnId;
22use crate::message::Message;
23
24/// Everything a context policy needs to shape one model request.
25pub struct ContextRequest<'a> {
26    /// Immutable durable head whose history is being assembled.
27    pub point: SessionPoint,
28    /// Turn about to invoke the model.
29    pub turn_id: TurnId,
30    /// One-based reason iteration within the turn.
31    pub iteration: usize,
32    /// Effective model after per-turn overrides.
33    pub model: &'a ModelSpec,
34    /// Optional input ceiling configured by the application.
35    pub token_limit: Option<usize>,
36    /// Complete replayed history at `point`.
37    pub messages: &'a [Message],
38    /// Paged access to the authoritative stream for policies that maintain
39    /// their own summaries, indexes, or historical windows.
40    pub events: &'a dyn EventStore,
41}
42
43/// Provenance for one contribution to assembled model context.
44#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
45pub struct ContextSource {
46    /// Human-readable source family such as `history`, `summary`, or `memory`.
47    pub kind: String,
48    /// Optional first durable point represented by this contribution.
49    pub from: Option<SessionPoint>,
50    /// Optional last durable point represented by this contribution.
51    pub through: Option<SessionPoint>,
52}
53
54/// Durable observational event produced while assembling context.
55#[derive(Debug, Clone, PartialEq)]
56pub struct ContextEvent {
57    /// Dot-notation event type such as `context.compacted`.
58    pub event_type: String,
59    /// Context-policy-owned event data.
60    pub payload: serde_json::Value,
61}
62
63/// Model messages plus the accounting and provenance behind them.
64#[derive(Debug, Clone, PartialEq)]
65pub struct ContextAssembly {
66    /// Messages sent to the provider.
67    pub messages: Vec<Message>,
68    /// Optional estimated provider input tokens.
69    pub estimated_tokens: Option<usize>,
70    /// Contributions used to construct `messages`.
71    pub sources: Vec<ContextSource>,
72    /// Observational events the engine persists before invoking the model.
73    pub events: Vec<ContextEvent>,
74}
75
76impl ContextAssembly {
77    /// An assembly containing only `messages`.
78    pub fn new(messages: Vec<Message>) -> Self {
79        Self {
80            messages,
81            estimated_tokens: None,
82            sources: Vec::new(),
83            events: Vec::new(),
84        }
85    }
86
87    /// Split provider messages from the observational events the engine must
88    /// persist before invoking the model.
89    pub fn into_messages_and_events(self) -> (Vec<Message>, Vec<EventData>) {
90        let mut events = Vec::new();
91        if self.estimated_tokens.is_some() || !self.sources.is_empty() {
92            events.push(EventData::custom(
93                "context.assembled",
94                serde_json::json!({
95                    "estimated_tokens": self.estimated_tokens,
96                    "sources": self.sources,
97                }),
98            ));
99        }
100        events.extend(
101            self.events
102                .into_iter()
103                .map(|event| EventData::custom(event.event_type, event.payload)),
104        );
105        (self.messages, events)
106    }
107}
108
109/// Decides what a turn actually sends to the model.
110///
111/// Sits between replay and the reason atom, so trimming, compaction and
112/// memory injection are implementations of this seam rather than changes to
113/// the turn machine — and none of them touch the event log, which keeps the
114/// real history.
115#[async_trait]
116pub trait ContextAssembler: Send + Sync {
117    /// Turn replayed history into what this turn actually sends to the
118    /// model.
119    ///
120    /// This is where trimming, compaction and memory injection belong: the
121    /// event log keeps the real history, and only what this returns reaches
122    /// the provider. Called once per reason step, so a resumed session gets
123    /// the same treatment as a fresh one.
124    /// The request carries the immutable history point, effective model,
125    /// iteration, optional token ceiling, and full replayed messages. The
126    /// result may report accounting, provenance, and observational events in
127    /// addition to the provider-facing messages.
128    async fn assemble(&self, request: ContextRequest<'_>) -> Result<ContextAssembly>;
129}
130
131/// Sends the full history unchanged — the default.
132#[derive(Debug, Default, Clone, Copy)]
133pub struct PassthroughContextAssembler;
134
135#[async_trait]
136impl ContextAssembler for PassthroughContextAssembler {
137    async fn assemble(&self, request: ContextRequest<'_>) -> Result<ContextAssembly> {
138        Ok(ContextAssembly {
139            messages: request.messages.to_vec(),
140            estimated_tokens: None,
141            sources: Vec::new(),
142            events: Vec::new(),
143        })
144    }
145}
146
147#[cfg(test)]
148mod tests {
149    use super::*;
150
151    #[test]
152    fn passthrough_returns_history_unchanged() {
153        let messages = vec![Message::user("hi"), Message::assistant("hello")];
154        let assembler = PassthroughContextAssembler;
155
156        // core has no async runtime dependency; a bare poll suffices since
157        // this implementation never suspends.
158        use std::pin::pin;
159        use std::task::{Context, Poll, Waker};
160        let model = ModelSpec::llmsim();
161        let session_id = crate::id::SessionId::new();
162        let events = crate::event_log::InMemoryEventLog::new();
163        let future = assembler.assemble(ContextRequest {
164            point: SessionPoint::new(session_id, 2),
165            turn_id: TurnId::new(),
166            iteration: 1,
167            model: &model,
168            token_limit: None,
169            messages: &messages,
170            events: &events,
171        });
172        let mut future = pin!(future);
173        let waker = Waker::noop();
174        let mut cx = Context::from_waker(waker);
175        let result = loop {
176            if let Poll::Ready(output) = future.as_mut().poll(&mut cx) {
177                break output;
178            }
179        };
180        assert_eq!(result.unwrap().messages, messages);
181    }
182}