1use 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
63pub struct AgentContext<D = ()> {
79 core: ExecutionContext,
80 deps: D,
81}
82
83impl<D> AgentContext<D> {
84 pub const fn new(core: ExecutionContext, deps: D) -> Self {
86 Self { core, deps }
87 }
88
89 pub const fn core(&self) -> &ExecutionContext {
93 &self.core
94 }
95
96 pub const fn core_mut(&mut self) -> &mut ExecutionContext {
101 &mut self.core
102 }
103
104 pub const fn deps(&self) -> &D {
108 &self.deps
109 }
110
111 pub const fn deps_mut(&mut self) -> &mut D {
116 &mut self.deps
117 }
118
119 #[allow(clippy::missing_const_for_fn)]
122 pub fn into_parts(self) -> (ExecutionContext, D) {
123 (self.core, self.deps)
124 }
125
126 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 pub const fn cancellation(&self) -> &CancellationToken {
145 self.core.cancellation()
146 }
147
148 pub const fn deadline(&self) -> Option<Instant> {
150 self.core.deadline()
151 }
152
153 pub fn thread_id(&self) -> Option<&str> {
155 self.core.thread_id()
156 }
157
158 pub const fn tenant_id(&self) -> &TenantId {
162 self.core.tenant_id()
163 }
164
165 pub fn run_id(&self) -> Option<&str> {
168 self.core.run_id()
169 }
170
171 pub fn idempotency_key(&self) -> Option<&str> {
175 self.core.idempotency_key()
176 }
177
178 pub fn is_cancelled(&self) -> bool {
180 self.core.is_cancelled()
181 }
182
183 pub const fn extensions(&self) -> &Extensions {
185 self.core.extensions()
186 }
187
188 #[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 #[must_use]
200 pub fn run_budget(&self) -> Option<Arc<RunBudget>> {
201 self.core.run_budget()
202 }
203
204 #[must_use]
206 pub fn audit_sink(&self) -> Option<Arc<AuditSinkHandle>> {
207 self.core.audit_sink()
208 }
209
210 #[must_use]
213 pub fn tool_progress_sink(&self) -> Option<Arc<ToolProgressSinkHandle>> {
214 self.core.tool_progress_sink()
215 }
216
217 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 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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 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 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 assert_eq!(child.deps(), parent.deps());
445 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 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}