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