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}