behest_context/lib.rs
1//! Layered context traits for the behest agent runtime.
2//!
3//! Inspired by ADK-Rust's `ReadonlyContext → CallbackContext → InvocationContext`
4//! hierarchy, this module defines progressively richer context interfaces that
5//! give agents, tools, hooks, and memory operations access to exactly the
6//! capabilities they need — no more, no less.
7//!
8//! # Hierarchy
9//!
10//! ```text
11//! ReadonlyContext (identity: invocation_id, session_id, user_id, app_name)
12//! ├── SessionContext (+ session state, session store)
13//! │ ├── RunContext (+ run_id, cancellation, deadline, event sink, budget)
14//! │ │ └── ToolContext (+ tool_call, emit_progress, search_memory)
15//! │ └── MemoryContext (+ active_window, demote, compact)
16//! └── HookContext (+ current_state, snapshot)
17//! ```
18//!
19//! # Design constraints
20//!
21//! - Each layer adds specific, well-defined capabilities.
22//! - Context does not become a "global service locator" — each trait is
23//! narrowly scoped to what the consumer needs.
24//! - Tool can access session info and stream progress, but cannot access
25//! internal runtime state.
26//! - Every trait has clear ownership and lifetime semantics.
27
28#![forbid(unsafe_code)]
29#![deny(missing_docs)]
30#![deny(unreachable_pub)]
31
32use std::time::Instant;
33
34use behest_core::id::RunId;
35use behest_core::message::Message;
36use behest_core::run::RunState;
37use behest_core::tool_types::ToolCall;
38use serde_json::Value;
39use tokio::sync::watch;
40
41mod factory;
42mod impls;
43pub use factory::{
44 ContextAdapter, ContextFactory, ContextInput, ContextOutput, ContextResult, FunctionAdapter,
45 StaticAdapter,
46};
47pub use impls::{
48 AppContext, HookContextImpl, MemoryContextImpl, RunContextImpl, SessionContextImpl,
49 ToolContextImpl,
50};
51
52/// The minimum context available to any participant in the system.
53///
54/// Provides read-only identity information: who is making the request,
55/// which session it belongs to, and which application is running.
56pub trait ReadonlyContext {
57 /// Returns the unique invocation identifier for this run.
58 fn invocation_id(&self) -> &str;
59
60 /// Returns the session identifier.
61 fn session_id(&self) -> &str;
62
63 /// Returns the authenticated user identifier.
64 fn user_id(&self) -> &str;
65
66 /// Returns the application name.
67 fn app_name(&self) -> &str;
68}
69
70/// Context available during a session's lifetime.
71///
72/// Extends [`ReadonlyContext`] with mutable session state and access
73/// to the session store for persistence.
74pub trait SessionContext: ReadonlyContext {
75 /// Returns the current session state (key-value store scoped to the session).
76 fn session_state(&self) -> &SessionState;
77
78 /// Returns mutable access to session state.
79 fn session_state_mut(&mut self) -> &mut SessionState;
80}
81
82/// A key-value state store scoped to a single session.
83///
84/// Supports typed key prefixes (`user:`, `app:`, `temp:`) for
85/// different scoping semantics.
86#[derive(Debug, Clone, Default)]
87pub struct SessionState {
88 entries: std::collections::HashMap<String, Value>,
89}
90
91impl SessionState {
92 /// Creates an empty session state.
93 #[must_use]
94 pub fn new() -> Self {
95 Self::default()
96 }
97
98 /// Sets a value in the session state.
99 pub fn set(&mut self, key: impl Into<String>, value: Value) {
100 self.entries.insert(key.into(), value);
101 }
102
103 /// Gets a value from the session state.
104 #[must_use]
105 pub fn get(&self, key: &str) -> Option<&Value> {
106 self.entries.get(key)
107 }
108
109 /// Removes a value from the session state.
110 pub fn remove(&mut self, key: &str) -> Option<Value> {
111 self.entries.remove(key)
112 }
113
114 /// Returns all entries in the session state.
115 #[must_use]
116 pub fn all(&self) -> &std::collections::HashMap<String, Value> {
117 &self.entries
118 }
119}
120
121/// Context available during a single run invocation.
122///
123/// Extends [`SessionContext`] with run-level controls: cancellation,
124/// deadlines, event output, and token budget tracking.
125pub trait RunContext: SessionContext {
126 /// Returns the unique run identifier.
127 fn run_id(&self) -> &RunId;
128
129 /// Returns a cancellation token for cooperative cancellation.
130 fn cancellation_token(&self) -> &tokio_util::sync::CancellationToken;
131
132 /// Returns the deadline for this run, if any.
133 fn deadline(&self) -> Option<Instant>;
134
135 /// Returns the event sink for emitting structured events.
136 fn event_sink(&self) -> &EventSink;
137
138 /// Returns the token budget tracker for this run.
139 fn budget(&self) -> &RunBudget;
140}
141
142/// A sink for emitting events during a run.
143///
144/// Events are sent to all registered subscribers (local broadcast)
145/// and optionally forwarded to external publishers.
146#[derive(Clone)]
147pub struct EventSink {
148 tx: watch::Sender<Option<Value>>,
149}
150
151impl std::fmt::Debug for EventSink {
152 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
153 f.debug_struct("EventSink")
154 .field("receiver_count", &self.tx.receiver_count())
155 .finish()
156 }
157}
158
159impl EventSink {
160 /// Creates a new event sink.
161 #[must_use]
162 pub fn new() -> Self {
163 let (tx, _) = watch::channel(None);
164 Self { tx }
165 }
166
167 /// Emits an event to all subscribers.
168 ///
169 /// Returns the number of active subscribers that received the event.
170 pub fn emit(&self, event: Value) -> usize {
171 self.tx.send_if_modified(|current| {
172 *current = Some(event);
173 true
174 });
175 self.tx.receiver_count()
176 }
177
178 /// Creates a receiver for this sink.
179 #[must_use]
180 pub fn subscribe(&self) -> watch::Receiver<Option<Value>> {
181 self.tx.subscribe()
182 }
183}
184
185impl Default for EventSink {
186 fn default() -> Self {
187 Self::new()
188 }
189}
190
191/// Tracks the token budget for a single run.
192#[derive(Debug, Clone)]
193pub struct RunBudget {
194 max_tokens: Option<usize>,
195 used_tokens: usize,
196}
197
198impl RunBudget {
199 /// Creates a new budget with an optional maximum.
200 #[must_use]
201 pub fn new(max_tokens: Option<usize>) -> Self {
202 Self {
203 max_tokens,
204 used_tokens: 0,
205 }
206 }
207
208 /// Records token usage.
209 pub fn consume(&mut self, tokens: usize) {
210 self.used_tokens += tokens;
211 }
212
213 /// Returns the remaining token budget.
214 /// Returns `None` if there is no maximum.
215 #[must_use]
216 pub fn remaining(&self) -> Option<usize> {
217 self.max_tokens
218 .map(|max| max.saturating_sub(self.used_tokens))
219 }
220
221 /// Returns the total tokens consumed so far.
222 #[must_use]
223 pub fn used(&self) -> usize {
224 self.used_tokens
225 }
226}
227
228/// Context for tool execution.
229///
230/// Extends [`RunContext`] with tool-specific capabilities:
231/// - Access to the current tool call
232/// - Streaming progress output
233/// - Memory search (for tools that need context from long-term memory)
234pub trait ToolContext: RunContext {
235 /// Returns the tool call being executed.
236 fn tool_call(&self) -> &ToolCall;
237
238 /// Emits a progress update for the current tool execution.
239 ///
240 /// Progress events are streamed to consumers so they can observe
241 /// long-running tool operations in real time.
242 fn emit_progress(&self, status: &str, data: Value) {
243 let progress = serde_json::json!({
244 "type": "tool_progress",
245 "call_id": self.tool_call().id,
246 "tool_name": self.tool_call().name,
247 "status": status,
248 "data": data,
249 });
250 self.event_sink().emit(progress);
251 }
252
253 /// Searches long-term memory for context relevant to the current tool call.
254 ///
255 /// The default implementation returns an empty result. Implementations
256 /// with access to an embedding store should override this.
257 fn search_memory(&self, _query: &str, _limit: usize) -> Vec<MemoryEntry> {
258 Vec::new()
259 }
260}
261
262/// An entry retrieved from long-term memory.
263#[derive(Debug, Clone)]
264pub struct MemoryEntry {
265 /// The memory content.
266 pub content: String,
267 /// Relevance score (higher is more relevant).
268 pub score: f32,
269 /// Source identifier (e.g., session ID, document name).
270 pub source: Option<String>,
271}
272
273/// Context for memory operations (demotion, compaction).
274///
275/// Extends [`SessionContext`] with access to the active window
276/// and the ability to demote or compact messages.
277pub trait MemoryContext: SessionContext {
278 /// Returns the current active window (recent messages in context).
279 fn active_window(&self) -> &[Message];
280
281 /// Demotes messages from the active window to long-term storage.
282 ///
283 /// Returns an error if the demotion hook is not configured or
284 /// the storage backend is unavailable.
285 fn demote(&self, messages: Vec<Message>) -> Result<(), String>;
286
287 /// Compacts messages into a summary and injects it back into the
288 /// active window.
289 ///
290 /// Returns the generated summary text.
291 fn compact(&self, messages: Vec<Message>) -> Result<String, String>;
292}
293
294/// Context available to hooks observing the system.
295///
296/// Extends [`ReadonlyContext`] with the ability to inspect the current
297/// run state and take a snapshot for later replay.
298pub trait HookContext: ReadonlyContext {
299 /// Returns the current run state.
300 fn current_state(&self) -> &RunState;
301
302 /// Returns a snapshot of the current run for audit/replay.
303 fn snapshot(&self) -> RunSnapshot;
304}
305
306/// A snapshot of the current run state for audit and replay.
307#[derive(Debug, Clone)]
308pub struct RunSnapshot {
309 /// The state at the time of the snapshot.
310 pub state: RunState,
311 /// The run identifier.
312 pub run_id: RunId,
313 /// The session identifier.
314 pub session_id: String,
315 /// The number of iterations completed so far.
316 pub iteration: usize,
317 /// The current token usage.
318 pub tokens_used: usize,
319}