Skip to main content

entelix_core/
agent_context.rs

1//! Typed-deps carrier — separates infra context from operator-side handles.
2//!
3//! [`AgentContext<D>`] wraps an [`ExecutionContext`] (the D-free infra
4//! carrier — cancellation, tenant scope, deadline, audit sink,
5//! extensions) with a typed `D` slot for operator-side dependencies
6//! (database pool, HTTP client, tenant config, ...).
7//!
8//! ## Why split?
9//!
10//! Layers and the tower service spine (`PolicyLayer`, `OtelLayer`,
11//! `RetryService`, ...) operate on [`ExecutionContext`] and have no
12//! reason to know `D`. Forcing `D` through every layer factory
13//! leads to generic explosion. `AgentContext<D>` is the type the
14//! `Tool` and validator surfaces see; [`AgentContext::core`] is
15//! what layers see.
16//!
17//! Invariant 4 (Hand contract) and invariant 10 (no operator-side
18//! handles via dynamic context) together require a typed slot that
19//! does NOT ride [`Extensions`] — extensions are best-effort
20//! dynamic carriers; `D` is a *static* contract on the agent's
21//! shape and must be visible to the type system.
22//!
23//! ## `D` defaults to `()`
24//!
25//! Agents and tools that need no operator-side state stay
26//! `AgentContext<()>`. [`Default`], [`From<ExecutionContext>`], and
27//! the [`AgentContext::default`] constructor all target the unit
28//! shape so the deps-less path is ergonomically zero-cost.
29//!
30//! ```
31//! use entelix_core::AgentContext;
32//!
33//! // Deps-less — Default / From all target `()`.
34//! let ctx = AgentContext::default();
35//! assert_eq!(ctx.deps(), &());
36//!
37//! // Typed deps — operator-side handle threaded into tools.
38//! #[derive(Clone, Debug, PartialEq)]
39//! struct AppDeps {
40//!     tenant_label: &'static str,
41//! }
42//! let ctx = AgentContext::new(
43//!     Default::default(),
44//!     AppDeps { tenant_label: "acme" },
45//! );
46//! assert_eq!(ctx.deps().tenant_label, "acme");
47//! ```
48//!
49//! [`Extensions`]: crate::extensions::Extensions
50
51use std::sync::Arc;
52
53use tokio::time::Instant;
54
55use crate::audit::AuditSinkHandle;
56use crate::cancellation::CancellationToken;
57use crate::context::ExecutionContext;
58use crate::extensions::Extensions;
59use crate::run_budget::RunBudget;
60use crate::tenant_id::TenantId;
61use crate::tools::{ToolProgressSinkHandle, ToolProgressStatus};
62
63/// Per-run carrier of infra context + typed operator-side
64/// dependencies. `D` defaults to `()` so deps-less agents pay no
65/// type-system tax.
66///
67/// `AgentContext<D>` flows into the `Tool` and validator surfaces.
68/// Layers consume [`ExecutionContext`] only, reached via
69/// [`Self::core`]; this keeps the layer ecosystem (`tower::Service`
70/// spine) D-free and avoids generic explosion.
71///
72/// Cloning a context with `D: Clone` is shallow: the inner
73/// [`ExecutionContext`] clones cheaply (`Arc` refcounts on tenant
74/// id, extensions, audit sink) and `D` clones with whatever
75/// semantics the operator gave it. Cloning is the canonical path
76/// for sub-agent dispatch — children share the parent's deps and
77/// inherit a child cancellation token via [`Self::child`].
78pub struct AgentContext<D = ()> {
79    core: ExecutionContext,
80    deps: D,
81}
82
83impl<D> AgentContext<D> {
84    /// Construct from an explicit [`ExecutionContext`] and `D`.
85    pub const fn new(core: ExecutionContext, deps: D) -> Self {
86        Self { core, deps }
87    }
88
89    /// Borrow the wrapped [`ExecutionContext`]. Layers and the
90    /// tower service spine (`ModelInvocation` / `ToolInvocation`)
91    /// consume the core directly so they stay D-free.
92    pub const fn core(&self) -> &ExecutionContext {
93        &self.core
94    }
95
96    /// Mutable handle to the wrapped [`ExecutionContext`]. The
97    /// retry middleware reaches for this to stamp an idempotency
98    /// key on first entry of a retry loop; outside that path,
99    /// prefer the `with_*` builder methods.
100    pub const fn core_mut(&mut self) -> &mut ExecutionContext {
101        &mut self.core
102    }
103
104    /// Borrow the typed operator-side deps. Tools consume this
105    /// directly; the type system enforces that the deps shape
106    /// matches the agent's `D` parameter.
107    pub const fn deps(&self) -> &D {
108        &self.deps
109    }
110
111    /// Mutable handle to the typed deps. Operators threading
112    /// per-run mutable state (counters, accumulators) reach for
113    /// this; most deps are `Arc<...>` and immutable, so the
114    /// shared-borrow [`Self::deps`] suffices.
115    pub const fn deps_mut(&mut self) -> &mut D {
116        &mut self.deps
117    }
118
119    /// Decompose into `(core, deps)`. Useful when bridging to
120    /// APIs that consume each half independently.
121    #[allow(clippy::missing_const_for_fn)]
122    pub fn into_parts(self) -> (ExecutionContext, D) {
123        (self.core, self.deps)
124    }
125
126    /// Transform `D` into `E` while preserving the infra core.
127    /// Sub-agents that need a narrower deps shape than the parent
128    /// project the parent's deps through this combinator at
129    /// dispatch time.
130    pub fn map_deps<E, F>(self, f: F) -> AgentContext<E>
131    where
132        F: FnOnce(D) -> E,
133    {
134        AgentContext {
135            core: self.core,
136            deps: f(self.deps),
137        }
138    }
139
140    // ---- forwarders to ExecutionContext (ergonomics) -----------
141
142    /// Borrow the cancellation token (forwarded from
143    /// [`ExecutionContext::cancellation`]).
144    pub const fn cancellation(&self) -> &CancellationToken {
145        self.core.cancellation()
146    }
147
148    /// Returns the deadline if one was attached.
149    pub const fn deadline(&self) -> Option<Instant> {
150        self.core.deadline()
151    }
152
153    /// Borrow the thread identifier if one was attached.
154    pub fn thread_id(&self) -> Option<&str> {
155        self.core.thread_id()
156    }
157
158    /// Borrow the tenant identifier (invariant 11). Always
159    /// present — defaults to the process-shared
160    /// [`TenantId::default`] when not explicitly set.
161    pub const fn tenant_id(&self) -> &TenantId {
162        self.core.tenant_id()
163    }
164
165    /// Borrow the per-execute correlation id, if one has been
166    /// stamped.
167    pub fn run_id(&self) -> Option<&str> {
168        self.core.run_id()
169    }
170
171    /// Borrow the idempotency key for the current logical call,
172    /// if one has been stamped (by `RetryService` on first entry
173    /// of a retry loop, or pre-allocated by the caller).
174    pub fn idempotency_key(&self) -> Option<&str> {
175        self.core.idempotency_key()
176    }
177
178    /// Convenience: did the cancellation token fire?
179    pub fn is_cancelled(&self) -> bool {
180        self.core.is_cancelled()
181    }
182
183    /// Borrow the typed cross-cutting carrier.
184    pub const fn extensions(&self) -> &Extensions {
185        self.core.extensions()
186    }
187
188    /// Look up the extension entry registered for `T`. See
189    /// [`ExecutionContext::extension`].
190    #[must_use]
191    pub fn extension<T>(&self) -> Option<Arc<T>>
192    where
193        T: Send + Sync + 'static,
194    {
195        self.core.extension::<T>()
196    }
197
198    /// Borrow the [`RunBudget`] attached to the run, if any.
199    #[must_use]
200    pub fn run_budget(&self) -> Option<Arc<RunBudget>> {
201        self.core.run_budget()
202    }
203
204    /// Borrow the [`AuditSinkHandle`] attached to the run, if any.
205    #[must_use]
206    pub fn audit_sink(&self) -> Option<Arc<AuditSinkHandle>> {
207        self.core.audit_sink()
208    }
209
210    /// Borrow the [`ToolProgressSinkHandle`] attached to the run, if
211    /// any.
212    #[must_use]
213    pub fn tool_progress_sink(&self) -> Option<Arc<ToolProgressSinkHandle>> {
214        self.core.tool_progress_sink()
215    }
216
217    /// Emit a tool-phase transition with no metadata. Silent no-op
218    /// when no sink is attached or the call is outside a tool
219    /// dispatch. Fire-and-forget — sink failures stay inside the
220    /// sink. Forwards to [`ExecutionContext::record_phase`].
221    pub async fn record_phase(&self, phase: impl Into<String> + Send, status: ToolProgressStatus)
222    where
223        D: Sync,
224    {
225        self.core.record_phase(phase, status).await;
226    }
227
228    /// Emit a tool-phase transition with structured metadata.
229    /// Forwards to [`ExecutionContext::record_phase_with`].
230    pub async fn record_phase_with(
231        &self,
232        phase: impl Into<String> + Send,
233        status: ToolProgressStatus,
234        metadata: serde_json::Value,
235    ) where
236        D: Sync,
237    {
238        self.core.record_phase_with(phase, status, metadata).await;
239    }
240
241    // ---- builder methods (delegate to core) --------------------
242
243    /// Attach a deadline. Returns `self` for builder-style chaining.
244    #[must_use]
245    pub fn with_deadline(mut self, deadline: Instant) -> Self {
246        self.core = self.core.with_deadline(deadline);
247        self
248    }
249
250    /// Attach a `thread_id`.
251    #[must_use]
252    pub fn with_thread_id(mut self, thread_id: impl Into<String>) -> Self {
253        self.core = self.core.with_thread_id(thread_id);
254        self
255    }
256
257    /// Override the tenant scope.
258    #[must_use]
259    pub fn with_tenant_id(mut self, tenant_id: TenantId) -> Self {
260        self.core = self.core.with_tenant_id(tenant_id);
261        self
262    }
263
264    /// Attach a `run_id`.
265    #[must_use]
266    pub fn with_run_id(mut self, run_id: impl Into<String>) -> Self {
267        self.core = self.core.with_run_id(run_id);
268        self
269    }
270
271    /// Attach an idempotency key.
272    #[must_use]
273    pub fn with_idempotency_key(mut self, key: impl Into<String>) -> Self {
274        self.core = self.core.with_idempotency_key(key);
275        self
276    }
277
278    /// Attach a [`RunBudget`].
279    #[must_use]
280    pub fn with_run_budget(mut self, budget: RunBudget) -> Self {
281        self.core = self.core.with_run_budget(budget);
282        self
283    }
284
285    /// Attach an [`AuditSinkHandle`].
286    #[must_use]
287    pub fn with_audit_sink(mut self, handle: AuditSinkHandle) -> Self {
288        self.core = self.core.with_audit_sink(handle);
289        self
290    }
291
292    /// Attach a [`ToolProgressSinkHandle`].
293    #[must_use]
294    pub fn with_tool_progress_sink(mut self, handle: ToolProgressSinkHandle) -> Self {
295        self.core = self.core.with_tool_progress_sink(handle);
296        self
297    }
298
299    /// Attach a typed cross-cutting value to the [`Extensions`]
300    /// slot. See [`ExecutionContext::add_extension`] for the
301    /// invariant 10 boundary.
302    #[must_use]
303    pub fn add_extension<T>(mut self, value: T) -> Self
304    where
305        T: Send + Sync + 'static,
306    {
307        self.core = self.core.add_extension(value);
308        self
309    }
310}
311
312impl<D: Clone> AgentContext<D> {
313    /// Derive a scoped child. The child inherits `deadline`,
314    /// `thread_id`, `tenant_id`, `run_id`, `idempotency_key`, and
315    /// `extensions`, but holds a *child* cancellation token and a
316    /// clone of `deps`. Cancelling the parent cascades; cancelling
317    /// the child does not.
318    ///
319    /// Use for scope-bounded fan-out (sub-agent dispatch, scatter
320    /// branches) where a fail-fast leaf should signal still-running
321    /// siblings to abort cooperatively without tearing the whole
322    /// request down.
323    #[must_use]
324    pub fn child(&self) -> Self {
325        Self {
326            core: self.core.child(),
327            deps: self.deps.clone(),
328        }
329    }
330}
331
332impl<D: Clone> Clone for AgentContext<D> {
333    fn clone(&self) -> Self {
334        Self {
335            core: self.core.clone(),
336            deps: self.deps.clone(),
337        }
338    }
339}
340
341impl<D: std::fmt::Debug> std::fmt::Debug for AgentContext<D> {
342    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
343        f.debug_struct("AgentContext")
344            .field("core", &self.core)
345            .field("deps", &self.deps)
346            .finish()
347    }
348}
349
350impl Default for AgentContext<()> {
351    fn default() -> Self {
352        Self {
353            core: ExecutionContext::default(),
354            deps: (),
355        }
356    }
357}
358
359impl From<ExecutionContext> for AgentContext<()> {
360    fn from(core: ExecutionContext) -> Self {
361        Self { core, deps: () }
362    }
363}
364
365#[cfg(test)]
366#[allow(clippy::unwrap_used)]
367mod tests {
368    use super::*;
369
370    #[derive(Clone, Debug, PartialEq, Eq)]
371    struct AppDeps {
372        tenant_label: &'static str,
373    }
374
375    #[test]
376    fn default_targets_unit_deps() {
377        let ctx = AgentContext::<()>::default();
378        assert_eq!(ctx.deps(), &());
379        // Tenant defaults to the process-shared default tenant.
380        assert_eq!(ctx.tenant_id(), &TenantId::default());
381    }
382
383    #[test]
384    fn from_execution_context_wraps_with_unit_deps() {
385        let core = ExecutionContext::new().with_thread_id("t-1");
386        let ctx: AgentContext<()> = core.into();
387        assert_eq!(ctx.deps(), &());
388        assert_eq!(ctx.thread_id(), Some("t-1"));
389    }
390
391    #[test]
392    fn typed_deps_thread_through_constructor() {
393        let ctx = AgentContext::new(
394            ExecutionContext::default(),
395            AppDeps {
396                tenant_label: "acme",
397            },
398        );
399        assert_eq!(ctx.deps().tenant_label, "acme");
400    }
401
402    #[test]
403    fn forwarders_match_core() {
404        let core = ExecutionContext::new()
405            .with_thread_id("t-2")
406            .with_run_id("r-2");
407        let ctx = AgentContext::new(core.clone(), AppDeps { tenant_label: "x" });
408        assert_eq!(ctx.thread_id(), core.thread_id());
409        assert_eq!(ctx.run_id(), core.run_id());
410        assert_eq!(ctx.tenant_id(), core.tenant_id());
411        assert_eq!(ctx.is_cancelled(), core.is_cancelled());
412    }
413
414    #[test]
415    fn into_parts_decomposes_and_round_trips() {
416        let deps = AppDeps {
417            tenant_label: "round-trip",
418        };
419        let ctx = AgentContext::new(ExecutionContext::default(), deps.clone());
420        let (core, recovered) = ctx.into_parts();
421        assert_eq!(recovered, deps);
422        // Re-wrap and confirm the deps survive a parts round-trip.
423        let again = AgentContext::new(core, recovered);
424        assert_eq!(again.deps().tenant_label, "round-trip");
425    }
426
427    #[test]
428    fn map_deps_transforms_typed_handle() {
429        let ctx = AgentContext::new(
430            ExecutionContext::default(),
431            AppDeps {
432                tenant_label: "before",
433            },
434        );
435        let mapped = ctx.map_deps(|d| d.tenant_label.to_owned());
436        assert_eq!(mapped.deps(), "before");
437    }
438
439    #[test]
440    fn child_clones_deps_and_branches_cancellation() {
441        let parent = AgentContext::new(ExecutionContext::default(), AppDeps { tenant_label: "p" });
442        let child = parent.child();
443        // Deps clone.
444        assert_eq!(child.deps(), parent.deps());
445        // Cancelling the child does NOT cancel the parent.
446        child.cancellation().cancel();
447        assert!(child.is_cancelled());
448        assert!(!parent.is_cancelled());
449    }
450
451    #[test]
452    fn parent_cancellation_cascades_to_child() {
453        let parent = AgentContext::new(ExecutionContext::default(), AppDeps { tenant_label: "p" });
454        let child = parent.child();
455        parent.cancellation().cancel();
456        assert!(child.is_cancelled());
457    }
458
459    #[test]
460    fn with_deadline_delegates_to_core() {
461        let deadline = Instant::now() + std::time::Duration::from_mins(1);
462        let ctx = AgentContext::default().with_deadline(deadline);
463        assert_eq!(ctx.deadline(), Some(deadline));
464        assert_eq!(ctx.core().deadline(), Some(deadline));
465    }
466
467    #[test]
468    fn with_thread_id_threads_through_core() {
469        let ctx = AgentContext::default().with_thread_id("t-3");
470        assert_eq!(ctx.thread_id(), Some("t-3"));
471        assert_eq!(ctx.core().thread_id(), Some("t-3"));
472    }
473
474    #[test]
475    fn with_tenant_id_overrides_default() {
476        let tid = TenantId::new("isolated");
477        let ctx = AgentContext::default().with_tenant_id(tid.clone());
478        assert_eq!(ctx.tenant_id(), &tid);
479    }
480
481    #[test]
482    fn with_run_id_attaches_correlation() {
483        let ctx = AgentContext::default().with_run_id("run-99");
484        assert_eq!(ctx.run_id(), Some("run-99"));
485    }
486
487    #[test]
488    fn with_idempotency_key_threads_through_core() {
489        let ctx = AgentContext::default().with_idempotency_key("idem-99");
490        assert_eq!(ctx.idempotency_key(), Some("idem-99"));
491    }
492
493    #[derive(Debug, PartialEq, Eq)]
494    struct WorkspaceCtx {
495        repo: &'static str,
496    }
497
498    #[test]
499    fn add_extension_typed_lookup_via_forwarder() {
500        let ctx = AgentContext::default().add_extension(WorkspaceCtx { repo: "entelix" });
501        let got = ctx.extension::<WorkspaceCtx>().unwrap();
502        assert_eq!(*got, WorkspaceCtx { repo: "entelix" });
503    }
504
505    #[test]
506    fn add_extension_does_not_alter_deps() {
507        let ctx = AgentContext::new(
508            ExecutionContext::default(),
509            AppDeps {
510                tenant_label: "preserve",
511            },
512        )
513        .add_extension(WorkspaceCtx { repo: "entelix" });
514        assert_eq!(ctx.deps().tenant_label, "preserve");
515        assert!(ctx.extension::<WorkspaceCtx>().is_some());
516    }
517
518    #[test]
519    fn run_budget_forwarder_returns_attached_handle() {
520        let budget = RunBudget::default();
521        let ctx = AgentContext::default().with_run_budget(budget);
522        assert!(ctx.run_budget().is_some());
523    }
524
525    #[test]
526    fn clone_shares_extensions_via_arc_refcount() {
527        let original = AgentContext::default().add_extension(WorkspaceCtx { repo: "entelix" });
528        let cloned = original.clone();
529        // Both see the same extension entry.
530        assert!(original.extension::<WorkspaceCtx>().is_some());
531        assert!(cloned.extension::<WorkspaceCtx>().is_some());
532    }
533
534    #[test]
535    fn debug_includes_core_and_deps() {
536        let ctx = AgentContext::new(
537            ExecutionContext::default(),
538            AppDeps {
539                tenant_label: "debug",
540            },
541        );
542        let formatted = format!("{ctx:?}");
543        assert!(formatted.contains("AgentContext"));
544        assert!(formatted.contains("debug"));
545    }
546}