Skip to main content

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}