Skip to main content

everruns_core/
traits.rs

1// Core traits for pluggable backends
2//
3// These traits allow the agent loop to be used with different backends:
4// - In-memory implementations for examples and testing
5// - Database implementations for production
6// - Channel-based implementations for streaming
7
8use crate::agent::Agent;
9use crate::harness::Harness;
10use crate::llm_models::LlmProviderType;
11use crate::session_file::{FileInfo, FileStat, GrepMatch, InitialFile, SessionFile};
12use crate::tool_types::{ToolCall, ToolDefinition, ToolResult};
13use crate::typed_id::{AgentId, HarnessId, ImageId, ModelId, SessionId};
14use async_trait::async_trait;
15use chrono::{DateTime, Utc};
16use std::any::{Any, TypeId};
17use std::collections::HashMap;
18use std::sync::Arc;
19use uuid::Uuid;
20
21/// Build a map of tool names to definitions for efficient lookup
22fn build_tool_map(tool_defs: &[ToolDefinition]) -> HashMap<&str, &ToolDefinition> {
23    tool_defs.iter().map(|def| (def.name(), def)).collect()
24}
25
26use crate::error::Result;
27
28// ============================================================================
29// AgentStore - For retrieving agent configurations
30// ============================================================================
31
32/// Trait for retrieving agent configurations
33///
34/// Implementations can:
35/// - Load agents from a database
36/// - Keep agents in memory for testing
37/// - Load agents from a configuration file
38#[async_trait]
39pub trait AgentStore: Send + Sync {
40    /// Get an agent by ID
41    async fn get_agent(&self, agent_id: AgentId) -> Result<Option<Agent>>;
42}
43
44#[async_trait]
45impl<T: AgentStore + ?Sized> AgentStore for std::sync::Arc<T> {
46    async fn get_agent(&self, agent_id: AgentId) -> Result<Option<Agent>> {
47        (**self).get_agent(agent_id).await
48    }
49}
50
51// ============================================================================
52// HarnessStore - For retrieving harness configurations
53// ============================================================================
54
55/// Trait for retrieving harness configurations
56///
57/// Implementations can:
58/// - Load harnesses from a database
59/// - Keep harnesses in memory for testing
60///
61/// Returns the harness inheritance chain (root-to-leaf) so the caller
62/// can fold each harness as an `AgentConfigOverlay`. DB-backed stores
63/// return the raw chain; gRPC-backed stores may return a single
64/// pre-merged harness (functionally equivalent when folded).
65#[async_trait]
66pub trait HarnessStore: Send + Sync {
67    /// Get the harness inheritance chain, root-to-leaf.
68    ///
69    /// Returns `Ok(vec![])` if the harness does not exist.
70    /// A harness with no parent returns a single-element vec.
71    async fn get_harness_chain(&self, harness_id: HarnessId) -> Result<Vec<Harness>>;
72}
73
74#[async_trait]
75impl<T: HarnessStore + ?Sized> HarnessStore for std::sync::Arc<T> {
76    async fn get_harness_chain(&self, harness_id: HarnessId) -> Result<Vec<Harness>> {
77        (**self).get_harness_chain(harness_id).await
78    }
79}
80
81// ============================================================================
82// SessionStore - For retrieving session information
83// ============================================================================
84
85use crate::leased_resource::{LeasedResource, UpsertLeasedResource};
86use crate::session::Session;
87
88/// Trait for retrieving session configurations
89///
90/// Implementations can:
91/// - Load sessions from a database
92/// - Keep sessions in memory for testing
93#[async_trait]
94pub trait SessionStore: Send + Sync {
95    /// Get a session by ID
96    async fn get_session(&self, session_id: SessionId) -> Result<Option<Session>>;
97}
98
99#[async_trait]
100impl<T: SessionStore + ?Sized> SessionStore for std::sync::Arc<T> {
101    async fn get_session(&self, session_id: SessionId) -> Result<Option<Session>> {
102        (**self).get_session(session_id).await
103    }
104}
105
106/// Trait for updating mutable session metadata.
107#[async_trait]
108pub trait SessionMutator: Send + Sync {
109    /// Update a session's human-readable title.
110    async fn update_session_title(&self, session_id: SessionId, title: String) -> Result<Session>;
111}
112
113#[async_trait]
114impl<T: SessionMutator + ?Sized> SessionMutator for std::sync::Arc<T> {
115    async fn update_session_title(&self, session_id: SessionId, title: String) -> Result<Session> {
116        (**self).update_session_title(session_id, title).await
117    }
118}
119
120// ============================================================================
121// LlmProviderStore - For retrieving LLM provider configurations
122// ============================================================================
123
124/// Model information with provider details needed for LLM calls
125#[derive(Debug, Clone)]
126pub struct ModelWithProvider {
127    /// The model ID string to pass to the LLM API (e.g., "gpt-4o", "claude-3-opus")
128    pub model: String,
129    /// Provider type for factory selection
130    pub provider_type: LlmProviderType,
131    /// Decrypted API key (if configured)
132    pub api_key: Option<String>,
133    /// Optional base URL override
134    pub base_url: Option<String>,
135}
136
137/// Trait for retrieving LLM provider and model configurations
138///
139/// This trait abstracts the database lookup and API key decryption needed
140/// to create LLM providers at runtime.
141///
142/// Implementations can:
143/// - Load from a database with encrypted API keys
144/// - Use in-memory configurations for testing
145/// - Load from environment variables for development
146#[async_trait]
147pub trait LlmProviderStore: Send + Sync {
148    /// Get model with provider info by model ID
149    ///
150    /// Returns the model string ID, provider type, decrypted API key, and base URL
151    /// needed to create an LLM provider via the factory.
152    async fn get_model_with_provider(&self, model_id: ModelId)
153    -> Result<Option<ModelWithProvider>>;
154
155    /// Get the default model with provider info
156    ///
157    /// Returns the system default model when an agent has no default_model_id set.
158    async fn get_default_model(&self) -> Result<Option<ModelWithProvider>>;
159}
160
161#[async_trait]
162impl<T: LlmProviderStore + ?Sized> LlmProviderStore for std::sync::Arc<T> {
163    async fn get_model_with_provider(
164        &self,
165        model_id: ModelId,
166    ) -> Result<Option<ModelWithProvider>> {
167        (**self).get_model_with_provider(model_id).await
168    }
169
170    async fn get_default_model(&self) -> Result<Option<ModelWithProvider>> {
171        (**self).get_default_model().await
172    }
173}
174
175// ============================================================================
176// ImageArtifactStore - For durable image persistence from tools
177// ============================================================================
178
179/// Metadata for a stored image artifact.
180#[derive(Debug, Clone)]
181pub struct StoredImageInfo {
182    pub id: ImageId,
183    pub filename: String,
184    pub content_type: String,
185    pub size_bytes: i64,
186    pub metadata: serde_json::Value,
187    pub created_at: DateTime<Utc>,
188}
189
190/// Stored image artifact with binary data.
191#[derive(Debug, Clone)]
192pub struct StoredImage {
193    pub info: StoredImageInfo,
194    pub data: Vec<u8>,
195}
196
197/// Input for creating a stored image artifact.
198#[derive(Debug, Clone)]
199pub struct CreateStoredImage {
200    pub filename: String,
201    pub content_type: String,
202    pub data: Vec<u8>,
203    pub metadata: serde_json::Value,
204}
205
206#[async_trait]
207pub trait ImageArtifactStore: Send + Sync {
208    /// Persist an image artifact and return its durable metadata.
209    async fn create_image(&self, input: CreateStoredImage) -> Result<StoredImageInfo>;
210
211    /// Load a stored image artifact including bytes.
212    async fn get_image(&self, image_id: ImageId) -> Result<Option<StoredImage>>;
213
214    /// Load stored image metadata without binary data.
215    async fn get_image_info(&self, image_id: ImageId) -> Result<Option<StoredImageInfo>>;
216}
217
218// ============================================================================
219// ProviderCredentialStore - For tool-side provider credential resolution
220// ============================================================================
221
222/// Provider credentials resolved for tool-side API clients.
223#[derive(Debug, Clone)]
224pub struct ProviderCredentials {
225    pub api_key: String,
226    pub base_url: Option<String>,
227}
228
229#[async_trait]
230pub trait ProviderCredentialStore: Send + Sync {
231    /// Resolve default credentials for a provider type (for example `openai`).
232    ///
233    /// Implementations may apply environment fallbacks internally, but tools
234    /// should never read provider env vars directly.
235    async fn get_default_provider_credentials(
236        &self,
237        provider_type: &str,
238    ) -> Result<Option<ProviderCredentials>>;
239}
240
241// ============================================================================
242// ToolExecutor - For executing tool calls
243// ============================================================================
244
245/// Trait for executing tool calls
246///
247/// Implementations handle the actual tool execution:
248/// - Webhook calls
249/// - Built-in function execution
250/// - Mock execution for testing
251#[async_trait]
252pub trait ToolExecutor: Send + Sync {
253    /// Execute a single tool call (without context)
254    ///
255    /// This is the legacy method that doesn't provide context to tools.
256    /// Use `execute_with_context` when context is available.
257    async fn execute(&self, tool_call: &ToolCall, tool_def: &ToolDefinition) -> Result<ToolResult>;
258
259    /// Execute a single tool call with context
260    ///
261    /// This method provides runtime context to tools that need it (like filesystem tools).
262    /// The default implementation delegates to `execute()`.
263    async fn execute_with_context(
264        &self,
265        tool_call: &ToolCall,
266        tool_def: &ToolDefinition,
267        _context: &ToolContext,
268    ) -> Result<ToolResult> {
269        // Default: delegate to execute(), ignoring context
270        self.execute(tool_call, tool_def).await
271    }
272
273    /// Execute multiple tool calls (default: sequential)
274    async fn execute_batch(
275        &self,
276        tool_calls: &[ToolCall],
277        tool_defs: &[ToolDefinition],
278    ) -> Result<Vec<ToolResult>> {
279        let mut results = Vec::with_capacity(tool_calls.len());
280
281        let tool_map = build_tool_map(tool_defs);
282
283        for tool_call in tool_calls {
284            let tool_def = tool_map.get(tool_call.name.as_str()).ok_or_else(|| {
285                crate::error::AgentLoopError::tool(format!(
286                    "Tool definition not found: {}",
287                    tool_call.name
288                ))
289            })?;
290
291            results.push(self.execute(tool_call, tool_def).await?);
292        }
293
294        Ok(results)
295    }
296
297    /// Execute multiple tool calls in parallel
298    async fn execute_parallel(
299        &self,
300        tool_calls: &[ToolCall],
301        tool_defs: &[ToolDefinition],
302    ) -> Result<Vec<ToolResult>>
303    where
304        Self: Sized,
305    {
306        use futures::future::join_all;
307
308        let tool_map = build_tool_map(tool_defs);
309
310        let futures: Vec<_> = tool_calls
311            .iter()
312            .map(|tool_call| async {
313                let tool_def = tool_map.get(tool_call.name.as_str()).ok_or_else(|| {
314                    crate::error::AgentLoopError::tool(format!(
315                        "Tool definition not found: {}",
316                        tool_call.name
317                    ))
318                })?;
319                self.execute(tool_call, tool_def).await
320            })
321            .collect();
322
323        let results = join_all(futures).await;
324        results.into_iter().collect()
325    }
326}
327
328// ============================================================================
329// SessionFileSystem - For session filesystem operations
330// ============================================================================
331
332/// Trait for session filesystem operations
333///
334/// This trait abstracts the session filesystem contract for tools and hosts.
335/// Implementations can:
336/// - Store files in a database (production)
337/// - Use an in-memory filesystem for testing
338/// - Project files onto real disk or object storage
339#[async_trait]
340pub trait SessionFileSystem: Send + Sync {
341    /// Read a file by path
342    async fn read_file(&self, session_id: SessionId, path: &str) -> Result<Option<SessionFile>>;
343
344    /// Write/create a file
345    async fn write_file(
346        &self,
347        session_id: SessionId,
348        path: &str,
349        content: &str,
350        encoding: &str,
351    ) -> Result<SessionFile>;
352
353    /// Write a file only if its current content snapshot still matches.
354    ///
355    /// Implementations backed by transactional storage should override this
356    /// with an atomic compare-and-set update.
357    async fn write_file_if_content_matches(
358        &self,
359        session_id: SessionId,
360        path: &str,
361        expected_content: &str,
362        expected_encoding: &str,
363        content: &str,
364        encoding: &str,
365    ) -> Result<Option<SessionFile>> {
366        let Some(existing) = self.read_file(session_id, path).await? else {
367            return Ok(None);
368        };
369
370        if existing.is_directory {
371            return Ok(None);
372        }
373
374        let current_content = existing.content.unwrap_or_default();
375        if current_content != expected_content || existing.encoding != expected_encoding {
376            return Ok(None);
377        }
378
379        self.write_file(session_id, path, content, encoding)
380            .await
381            .map(Some)
382    }
383
384    /// Delete a file or directory
385    async fn delete_file(&self, session_id: SessionId, path: &str, recursive: bool)
386    -> Result<bool>;
387
388    /// List files in a directory
389    async fn list_directory(&self, session_id: SessionId, path: &str) -> Result<Vec<FileInfo>>;
390
391    /// Get file metadata
392    async fn stat_file(&self, session_id: SessionId, path: &str) -> Result<Option<FileStat>>;
393
394    /// Search files by pattern (grep)
395    async fn grep_files(
396        &self,
397        session_id: SessionId,
398        pattern: &str,
399        path_pattern: Option<&str>,
400    ) -> Result<Vec<GrepMatch>>;
401
402    /// Create a directory
403    async fn create_directory(&self, session_id: SessionId, path: &str) -> Result<FileInfo>;
404
405    /// Seed a starter file into a session workspace.
406    async fn seed_initial_file(&self, session_id: SessionId, file: &InitialFile) -> Result<()> {
407        if file.is_readonly {
408            return Err(crate::error::AgentLoopError::store(
409                "read-only initial files require a SessionFileSystem-specific seed implementation",
410            ));
411        }
412        self.write_file(session_id, &file.path, &file.content, &file.encoding)
413            .await?;
414        Ok(())
415    }
416}
417
418#[async_trait]
419impl<T: SessionFileSystem + ?Sized> SessionFileSystem for std::sync::Arc<T> {
420    async fn read_file(&self, session_id: SessionId, path: &str) -> Result<Option<SessionFile>> {
421        (**self).read_file(session_id, path).await
422    }
423
424    async fn write_file(
425        &self,
426        session_id: SessionId,
427        path: &str,
428        content: &str,
429        encoding: &str,
430    ) -> Result<SessionFile> {
431        (**self)
432            .write_file(session_id, path, content, encoding)
433            .await
434    }
435
436    async fn write_file_if_content_matches(
437        &self,
438        session_id: SessionId,
439        path: &str,
440        expected_content: &str,
441        expected_encoding: &str,
442        content: &str,
443        encoding: &str,
444    ) -> Result<Option<SessionFile>> {
445        (**self)
446            .write_file_if_content_matches(
447                session_id,
448                path,
449                expected_content,
450                expected_encoding,
451                content,
452                encoding,
453            )
454            .await
455    }
456
457    async fn delete_file(
458        &self,
459        session_id: SessionId,
460        path: &str,
461        recursive: bool,
462    ) -> Result<bool> {
463        (**self).delete_file(session_id, path, recursive).await
464    }
465
466    async fn list_directory(&self, session_id: SessionId, path: &str) -> Result<Vec<FileInfo>> {
467        (**self).list_directory(session_id, path).await
468    }
469
470    async fn stat_file(&self, session_id: SessionId, path: &str) -> Result<Option<FileStat>> {
471        (**self).stat_file(session_id, path).await
472    }
473
474    async fn grep_files(
475        &self,
476        session_id: SessionId,
477        pattern: &str,
478        path_pattern: Option<&str>,
479    ) -> Result<Vec<GrepMatch>> {
480        (**self).grep_files(session_id, pattern, path_pattern).await
481    }
482
483    async fn create_directory(&self, session_id: SessionId, path: &str) -> Result<FileInfo> {
484        (**self).create_directory(session_id, path).await
485    }
486
487    async fn seed_initial_file(&self, session_id: SessionId, file: &InitialFile) -> Result<()> {
488        (**self).seed_initial_file(session_id, file).await
489    }
490}
491
492/// Backward-compatible alias for the old session filesystem trait name.
493pub use SessionFileSystem as SessionFileStore;
494
495/// Host-supplied values used by platform file-system factories.
496///
497/// The context is intentionally type-erased so `everruns-core` can own the
498/// platform contract without depending on server-only types such as
499/// `StorageBackend` or future object-storage clients.
500#[derive(Clone, Default)]
501pub struct SessionFileSystemFactoryContext {
502    values: Arc<HashMap<TypeId, Arc<dyn Any + Send + Sync>>>,
503}
504
505impl SessionFileSystemFactoryContext {
506    pub fn new() -> Self {
507        Self::default()
508    }
509
510    pub fn with<T: Any + Send + Sync>(mut self, value: Arc<T>) -> Self {
511        let values = Arc::make_mut(&mut self.values);
512        values.insert(TypeId::of::<T>(), value);
513        self
514    }
515
516    pub fn get<T: Any + Send + Sync>(&self) -> Option<Arc<T>> {
517        self.values
518            .get(&TypeId::of::<T>())
519            .and_then(|value| value.clone().downcast::<T>().ok())
520    }
521}
522
523/// Factory for deployment-selected session filesystem implementations.
524#[async_trait]
525pub trait SessionFileSystemFactory: Send + Sync {
526    /// Human-readable factory name for diagnostics.
527    fn name(&self) -> &'static str {
528        "SessionFileSystemFactory"
529    }
530
531    /// Whether this factory intentionally leaves filesystem selection to the
532    /// runtime default.
533    fn is_disabled(&self) -> bool {
534        false
535    }
536
537    /// Resolve a live filesystem from host-provided dependencies.
538    async fn create_session_file_system(
539        &self,
540        context: SessionFileSystemFactoryContext,
541    ) -> Result<Arc<dyn SessionFileSystem>>;
542}
543
544/// Default factory used when a platform does not configure session files.
545#[derive(Debug, Clone, Default)]
546pub struct DisabledSessionFileSystemFactory;
547
548#[async_trait]
549impl SessionFileSystemFactory for DisabledSessionFileSystemFactory {
550    fn name(&self) -> &'static str {
551        "DisabledSessionFileSystemFactory"
552    }
553
554    fn is_disabled(&self) -> bool {
555        true
556    }
557
558    async fn create_session_file_system(
559        &self,
560        _context: SessionFileSystemFactoryContext,
561    ) -> Result<Arc<dyn SessionFileSystem>> {
562        Err(crate::error::AgentLoopError::config(
563            "session filesystem is disabled",
564        ))
565    }
566}
567
568// ============================================================================
569// SessionStorageStore - For session key/value and secret storage
570// ============================================================================
571
572/// Info about a stored key (without its value)
573#[derive(Debug, Clone)]
574pub struct KeyInfo {
575    pub key: String,
576    pub created_at: chrono::DateTime<chrono::Utc>,
577    pub updated_at: chrono::DateTime<chrono::Utc>,
578}
579
580/// Info about a stored secret (without its value)
581#[derive(Debug, Clone)]
582pub struct SecretInfo {
583    pub name: String,
584    pub created_at: chrono::DateTime<chrono::Utc>,
585    pub updated_at: chrono::DateTime<chrono::Utc>,
586}
587
588/// Trait for session key/value and secret storage operations
589///
590/// This trait abstracts storage operations for tools that need to persist
591/// data within a session. Implementations can:
592/// - Store data in a database (production)
593/// - Use in-memory storage for testing
594///
595/// Key/value storage is for general data that doesn't need encryption.
596/// Secret storage is for sensitive data that is encrypted at rest.
597#[async_trait]
598pub trait SessionStorageStore: Send + Sync {
599    // Key/Value operations (plain text)
600
601    /// Set a key/value pair (creates or updates)
602    async fn set_value(&self, session_id: SessionId, key: &str, value: &str) -> Result<()>;
603
604    /// Get a value by key
605    async fn get_value(&self, session_id: SessionId, key: &str) -> Result<Option<String>>;
606
607    /// Delete a key/value pair
608    async fn delete_value(&self, session_id: SessionId, key: &str) -> Result<bool>;
609
610    /// List all keys in a session
611    async fn list_keys(&self, session_id: SessionId) -> Result<Vec<KeyInfo>>;
612
613    // Secret operations (encrypted)
614
615    /// Set a secret (creates or updates, value is encrypted before storage)
616    async fn set_secret(&self, session_id: SessionId, name: &str, value: &str) -> Result<()>;
617
618    /// Get a secret by name (value is decrypted before returning)
619    async fn get_secret(&self, session_id: SessionId, name: &str) -> Result<Option<String>>;
620
621    /// Delete a secret
622    async fn delete_secret(&self, session_id: SessionId, name: &str) -> Result<bool>;
623
624    /// List all secret names in a session (without values)
625    async fn list_secrets(&self, session_id: SessionId) -> Result<Vec<SecretInfo>>;
626}
627
628// ============================================================================
629// SessionScheduleStore - For session-scoped schedule operations
630// ============================================================================
631
632use crate::session_schedule::SessionSchedule;
633use crate::typed_id::ScheduleId;
634
635/// Trait for session schedule CRUD operations.
636///
637/// Used by scheduling tools to create, cancel, and list schedules.
638#[async_trait]
639pub trait SessionScheduleStore: Send + Sync {
640    /// Create a new schedule for a session.
641    async fn create_schedule(
642        &self,
643        session_id: SessionId,
644        description: String,
645        cron_expression: Option<String>,
646        scheduled_at: Option<chrono::DateTime<chrono::Utc>>,
647        timezone: String,
648    ) -> Result<SessionSchedule>;
649
650    /// Cancel (disable) a schedule.
651    async fn cancel_schedule(
652        &self,
653        session_id: SessionId,
654        schedule_id: ScheduleId,
655    ) -> Result<SessionSchedule>;
656
657    /// List schedules for a session.
658    async fn list_schedules(&self, session_id: SessionId) -> Result<Vec<SessionSchedule>>;
659
660    /// Count active (enabled) schedules for a session.
661    async fn count_active_schedules(&self, session_id: SessionId) -> Result<u32>;
662}
663
664// ============================================================================
665// SessionResourceRegistry - Generic session-scoped resource registry
666// ============================================================================
667
668/// Generic registry of resources active alongside a session.
669///
670/// Capabilities register resources here (sandboxes, subagents, browser sessions).
671/// Agents query it ("what's running?"), infrastructure scans it for cleanup.
672/// See `specs/session-resources.md`.
673#[async_trait]
674pub trait SessionResourceRegistry: Send + Sync {
675    /// Register a resource (or update if resource_id already exists for this session).
676    async fn register(
677        &self,
678        entry: crate::session_resource::RegisterSessionResource,
679    ) -> Result<crate::session_resource::SessionResourceEntry>;
680
681    /// Update the status of a registered resource.
682    async fn update_status(
683        &self,
684        session_id: SessionId,
685        resource_id: &str,
686        status: crate::session_resource::SessionResourceStatus,
687    ) -> Result<Option<crate::session_resource::SessionResourceEntry>>;
688
689    /// Get a specific resource by ID.
690    async fn get(
691        &self,
692        session_id: SessionId,
693        resource_id: &str,
694    ) -> Result<Option<crate::session_resource::SessionResourceEntry>>;
695
696    /// List resources for a session, optionally filtered.
697    async fn list(
698        &self,
699        session_id: SessionId,
700        filter: Option<&crate::session_resource::SessionResourceFilter>,
701    ) -> Result<Vec<crate::session_resource::SessionResourceEntry>>;
702
703    /// Remove a resource from the registry.
704    async fn deregister(&self, session_id: SessionId, resource_id: &str) -> Result<bool>;
705}
706
707// ============================================================================
708// LeasedResourceStore - For lifecycle-managed external resources
709// ============================================================================
710
711/// Trait for session-scoped leased resource operations.
712///
713/// Tools use this store to register or refresh leases when they create or use
714/// external provider resources. Cleanup workers operate through control-plane
715/// storage APIs directly so they can claim work across organizations.
716#[async_trait]
717pub trait LeasedResourceStore: Send + Sync {
718    /// Create or refresh a leased resource for a session.
719    ///
720    /// Implementations must treat this as an idempotent upsert keyed by the
721    /// provider-specific resource identity so repeated tool usage extends the
722    /// same lease instead of creating duplicate rows.
723    async fn upsert_resource(&self, input: UpsertLeasedResource) -> Result<LeasedResource>;
724
725    /// Mark a leased resource as explicitly released.
726    ///
727    /// This is the fast path for explicit user intent such as "close browser"
728    /// or "delete sandbox". It should transition the resource to `released`
729    /// without waiting for the durable cleanup worker to observe lease expiry.
730    async fn release_resource(
731        &self,
732        session_id: SessionId,
733        provider: &str,
734        resource_type: &str,
735        external_id: &str,
736    ) -> Result<Option<LeasedResource>>;
737
738    /// List leased resources currently associated with a session.
739    ///
740    /// Session surfaces use this for visibility. Released resources remain
741    /// visible so operators can inspect cleanup outcomes and failure history.
742    async fn list_resources(&self, session_id: SessionId) -> Result<Vec<LeasedResource>>;
743}
744
745// ============================================================================
746// ToolContext - Runtime context for tool execution
747// ============================================================================
748
749/// Type alias for the session SQL DB store trait object.
750pub type SessionSqlDbStoreRef = Arc<dyn crate::session_sqldb::SessionSqlDbStore>;
751
752/// Resolves user connection tokens (e.g. GitHub) lazily at tool execution time.
753///
754/// Instead of eagerly injecting tokens at session creation, tools call this
755/// resolver when they need a token. If the user hasn't connected, returns None.
756#[async_trait]
757pub trait UserConnectionResolver: Send + Sync {
758    /// Get a decrypted connection token for the given provider.
759    /// Returns None if the user has no connection for this provider.
760    async fn get_connection_token(
761        &self,
762        session_id: SessionId,
763        provider: &str,
764    ) -> Result<Option<String>>;
765
766    /// Resolve the user ID of the connection used for a session/provider pair.
767    ///
768    /// This is used by leased resources to bind cleanup to the same provider
769    /// identity that created the remote resource.
770    async fn get_connection_user(
771        &self,
772        _session_id: SessionId,
773        _provider: &str,
774    ) -> Result<Option<Uuid>> {
775        Ok(None)
776    }
777
778    /// Resolve a provider token for a specific user.
779    ///
780    /// Cleanup workers use this to avoid "first org member wins" behavior when
781    /// cleaning resources created by a specific provider connection owner.
782    async fn get_connection_token_for_user(
783        &self,
784        _user_id: Uuid,
785        _provider: &str,
786    ) -> Result<Option<String>> {
787        Ok(None)
788    }
789
790    /// Get provider-specific metadata stored alongside the connection.
791    /// Returns None if no metadata is stored or no connection exists.
792    async fn get_connection_metadata(
793        &self,
794        _session_id: SessionId,
795        _provider: &str,
796    ) -> Result<Option<serde_json::Value>> {
797        Ok(None)
798    }
799}
800
801// ============================================================================
802// BudgetChecker - For querying budget status from tools
803// ============================================================================
804
805/// Trait for checking budget status from within tool execution.
806///
807/// Implemented by gRPC adapters (worker → server) and direct adapters (in-process).
808/// Used by the `check_budget` tool to return real budget data to agents.
809/// The org_id is captured at construction time by the implementing adapter.
810#[async_trait]
811pub trait BudgetChecker: Send + Sync {
812    /// Check all budgets for a session and return a tool-friendly response.
813    async fn check_budgets(&self, session_id: &str) -> Result<crate::budget::BudgetToolResponse>;
814}
815
816// ============================================================================
817// PaymentAuthority - For capability-internal machine payments
818// ============================================================================
819
820/// Internal authority for paid capability operations.
821///
822/// Capabilities call this with fixed, typed requests. The model never receives a
823/// generic paid HTTP tool, wallet credentials, or payment payloads.
824#[async_trait]
825pub trait PaymentAuthority: Send + Sync {
826    async fn execute_machine_payment(
827        &self,
828        session_id: SessionId,
829        request: crate::payment::MachinePaymentRequest,
830    ) -> Result<crate::payment::MachinePaymentResponse>;
831}
832
833/// Runtime context provided to tools during execution.
834///
835/// This context contains:
836/// - Session ID for scoping operations
837/// - Optional stores for tools that need external access
838///
839/// Tools that need context-aware execution (like filesystem tools) can use
840/// the `execute_with_context` method on the Tool trait.
841#[derive(Clone)]
842pub struct ToolContext {
843    /// The session ID for the current execution
844    pub session_id: SessionId,
845
846    /// Optional file store for filesystem operations
847    pub file_store: Option<Arc<dyn SessionFileSystem>>,
848
849    /// Optional storage store for key/value and secret storage
850    pub storage_store: Option<Arc<dyn SessionStorageStore>>,
851
852    /// Optional durable image artifact store for tool-side media persistence.
853    pub image_store: Option<Arc<dyn ImageArtifactStore>>,
854
855    /// Optional provider credential store for tool-side API clients.
856    pub provider_credential_store: Option<Arc<dyn ProviderCredentialStore>>,
857
858    /// Optional system utility LLM service for capability internals.
859    pub utility_llm_service: Option<Arc<dyn crate::UtilityLlmService>>,
860
861    /// Optional outbound egress service for HTTP/API traffic.
862    pub egress_service: Option<Arc<dyn crate::EgressService>>,
863
864    /// Optional session SQL database store
865    pub sqldb_store: Option<SessionSqlDbStoreRef>,
866
867    /// Optional message retriever for tools that need conversation history access
868    pub message_retriever: Option<Arc<dyn crate::message_retriever::MessageRetriever>>,
869
870    /// Optional session store for tools that need session metadata access.
871    pub session_store: Option<Arc<dyn SessionStore>>,
872
873    /// Optional session mutator for tools that need to update session metadata.
874    pub session_mutator: Option<Arc<dyn SessionMutator>>,
875
876    /// Optional agent store for tools that need agent metadata access.
877    pub agent_store: Option<Arc<dyn AgentStore>>,
878
879    /// Optional resolver for user connection tokens (lazy GitHub token lookup, etc.)
880    pub connection_resolver: Option<Arc<dyn UserConnectionResolver>>,
881
882    /// Optional session schedule store for scheduling tools.
883    pub schedule_store: Option<Arc<dyn SessionScheduleStore>>,
884
885    /// Optional platform store for org-level management tools.
886    pub platform_store: Option<Arc<dyn crate::platform_store::PlatformStore>>,
887    /// Optional leased resource store for lifecycle-managed provider resources.
888    pub leased_resource_store: Option<Arc<dyn LeasedResourceStore>>,
889
890    /// Optional session resource registry — generic registry of active resources.
891    pub session_resource_registry: Option<Arc<dyn SessionResourceRegistry>>,
892
893    /// Optional event emitter for tools that need to stream progress updates.
894    /// When set, tools can emit `tool.progress` events during execution.
895    pub event_emitter: Option<Arc<dyn EventEmitter>>,
896
897    /// Event context for correlating progress events with the current tool call.
898    /// Set by ActAtom when constructing the ToolContext.
899    pub event_context: Option<crate::events::EventContext>,
900
901    /// The tool call ID for the current execution (set by ActAtom).
902    /// Used by tools to emit correlated progress events.
903    pub tool_call_id: Option<String>,
904    /// Optional capability registry for blueprint lookups.
905    pub capability_registry: Option<crate::capabilities::CapabilityRegistry>,
906
907    /// Optional registry of active built-in tools for meta-tools such as
908    /// `spawn_background` that need to inspect or delegate to sibling tools.
909    pub tool_registry: Option<Arc<crate::tools::ToolRegistry>>,
910
911    /// Optional memory store backend for persistent cross-session memory.
912    pub memory_store: Option<Arc<dyn crate::memory_store::MemoryStoreBackend>>,
913
914    /// Optional org ID for org-scoped operations (memory stores, etc.).
915    pub org_id: Option<crate::typed_id::OrgId>,
916
917    /// Merged network access list (harness ∩ agent ∩ session).
918    /// When set, tools that make HTTP requests must check URLs against this list.
919    pub network_access: Option<crate::network_access::NetworkAccessList>,
920
921    /// Resolved locale for localized tool behavior (BCP 47, e.g. `uk-UA`).
922    /// When set, tools that support localization use this to produce
923    /// locale-appropriate descriptions, error messages, and prompts.
924    pub locale: Option<String>,
925
926    /// Optional budget checker for the check_budget tool.
927    pub budget_checker: Option<Arc<dyn BudgetChecker>>,
928
929    /// Optional internal payment authority for paid capability tools.
930    pub payment_authority: Option<Arc<dyn PaymentAuthority>>,
931}
932
933impl ToolContext {
934    /// Create a new tool context with just a session ID
935    pub fn new(session_id: SessionId) -> Self {
936        Self {
937            session_id,
938            file_store: None,
939            storage_store: None,
940            image_store: None,
941            provider_credential_store: None,
942            utility_llm_service: None,
943            egress_service: None,
944            sqldb_store: None,
945            message_retriever: None,
946            session_store: None,
947            session_mutator: None,
948            agent_store: None,
949            connection_resolver: None,
950            schedule_store: None,
951            platform_store: None,
952            leased_resource_store: None,
953            session_resource_registry: None,
954            event_emitter: None,
955            event_context: None,
956            tool_call_id: None,
957            capability_registry: None,
958            tool_registry: None,
959            memory_store: None,
960            org_id: None,
961            network_access: None,
962            locale: None,
963            budget_checker: None,
964            payment_authority: None,
965        }
966    }
967
968    /// Create a context with a file store
969    pub fn with_file_store(session_id: SessionId, file_store: Arc<dyn SessionFileSystem>) -> Self {
970        Self {
971            session_id,
972            file_store: Some(file_store),
973            storage_store: None,
974            image_store: None,
975            provider_credential_store: None,
976            utility_llm_service: None,
977            egress_service: None,
978            sqldb_store: None,
979            message_retriever: None,
980            session_store: None,
981            session_mutator: None,
982            agent_store: None,
983            connection_resolver: None,
984            schedule_store: None,
985            platform_store: None,
986            leased_resource_store: None,
987            session_resource_registry: None,
988            event_emitter: None,
989            event_context: None,
990            tool_call_id: None,
991            capability_registry: None,
992            tool_registry: None,
993            memory_store: None,
994            org_id: None,
995            network_access: None,
996            locale: None,
997            budget_checker: None,
998            payment_authority: None,
999        }
1000    }
1001
1002    /// Create a context with a storage store
1003    pub fn with_storage_store(
1004        session_id: SessionId,
1005        storage_store: Arc<dyn SessionStorageStore>,
1006    ) -> Self {
1007        Self {
1008            session_id,
1009            file_store: None,
1010            storage_store: Some(storage_store),
1011            image_store: None,
1012            provider_credential_store: None,
1013            utility_llm_service: None,
1014            egress_service: None,
1015            sqldb_store: None,
1016            message_retriever: None,
1017            session_store: None,
1018            session_mutator: None,
1019            agent_store: None,
1020            connection_resolver: None,
1021            schedule_store: None,
1022            platform_store: None,
1023            leased_resource_store: None,
1024            session_resource_registry: None,
1025            event_emitter: None,
1026            event_context: None,
1027            tool_call_id: None,
1028            capability_registry: None,
1029            tool_registry: None,
1030            memory_store: None,
1031            org_id: None,
1032            network_access: None,
1033            locale: None,
1034            budget_checker: None,
1035            payment_authority: None,
1036        }
1037    }
1038
1039    /// Create a context with both file store and storage store
1040    pub fn with_stores(
1041        session_id: SessionId,
1042        file_store: Arc<dyn SessionFileSystem>,
1043        storage_store: Arc<dyn SessionStorageStore>,
1044    ) -> Self {
1045        Self {
1046            session_id,
1047            file_store: Some(file_store),
1048            storage_store: Some(storage_store),
1049            sqldb_store: None,
1050            image_store: None,
1051            provider_credential_store: None,
1052            utility_llm_service: None,
1053            egress_service: None,
1054            message_retriever: None,
1055            session_store: None,
1056            session_mutator: None,
1057            agent_store: None,
1058            connection_resolver: None,
1059            schedule_store: None,
1060            platform_store: None,
1061            leased_resource_store: None,
1062            session_resource_registry: None,
1063            event_emitter: None,
1064            event_context: None,
1065            tool_call_id: None,
1066            capability_registry: None,
1067            tool_registry: None,
1068            memory_store: None,
1069            org_id: None,
1070            network_access: None,
1071            locale: None,
1072            budget_checker: None,
1073            payment_authority: None,
1074        }
1075    }
1076
1077    /// Add a SQL database store to this context
1078    pub fn with_sqldb_store(mut self, sqldb_store: SessionSqlDbStoreRef) -> Self {
1079        self.sqldb_store = Some(sqldb_store);
1080        self
1081    }
1082
1083    /// Add a message retriever to this context
1084    pub fn with_message_retriever(
1085        mut self,
1086        retriever: Arc<dyn crate::message_retriever::MessageRetriever>,
1087    ) -> Self {
1088        self.message_retriever = Some(retriever);
1089        self
1090    }
1091
1092    /// Add a session store to this context.
1093    pub fn with_session_store(mut self, store: Arc<dyn SessionStore>) -> Self {
1094        self.session_store = Some(store);
1095        self
1096    }
1097
1098    /// Add a session mutator to this context.
1099    pub fn with_session_mutator(mut self, mutator: Arc<dyn SessionMutator>) -> Self {
1100        self.session_mutator = Some(mutator);
1101        self
1102    }
1103
1104    /// Add an agent store to this context.
1105    pub fn with_agent_store(mut self, store: Arc<dyn AgentStore>) -> Self {
1106        self.agent_store = Some(store);
1107        self
1108    }
1109
1110    /// Add a connection resolver to this context
1111    pub fn with_connection_resolver(mut self, resolver: Arc<dyn UserConnectionResolver>) -> Self {
1112        self.connection_resolver = Some(resolver);
1113        self
1114    }
1115
1116    /// Create a context with an image artifact store.
1117    pub fn with_image_store(
1118        session_id: SessionId,
1119        image_store: Arc<dyn ImageArtifactStore>,
1120    ) -> Self {
1121        Self {
1122            session_id,
1123            file_store: None,
1124            storage_store: None,
1125            image_store: Some(image_store),
1126            provider_credential_store: None,
1127            utility_llm_service: None,
1128            egress_service: None,
1129            sqldb_store: None,
1130            message_retriever: None,
1131            session_store: None,
1132            session_mutator: None,
1133            agent_store: None,
1134            connection_resolver: None,
1135            schedule_store: None,
1136            platform_store: None,
1137            leased_resource_store: None,
1138            session_resource_registry: None,
1139            event_emitter: None,
1140            event_context: None,
1141            tool_call_id: None,
1142            capability_registry: None,
1143            tool_registry: None,
1144            memory_store: None,
1145            org_id: None,
1146            network_access: None,
1147            locale: None,
1148            budget_checker: None,
1149            payment_authority: None,
1150        }
1151    }
1152
1153    /// Set the provider credential store on this context.
1154    pub fn with_provider_credential_store(
1155        mut self,
1156        store: Arc<dyn ProviderCredentialStore>,
1157    ) -> Self {
1158        self.provider_credential_store = Some(store);
1159        self
1160    }
1161
1162    /// Set the utility LLM service on this context.
1163    pub fn with_utility_llm_service(mut self, service: Arc<dyn crate::UtilityLlmService>) -> Self {
1164        self.utility_llm_service = Some(service);
1165        self
1166    }
1167
1168    /// Set the outbound egress service on this context.
1169    pub fn with_egress_service(mut self, service: Arc<dyn crate::EgressService>) -> Self {
1170        self.egress_service = Some(service);
1171        self
1172    }
1173
1174    /// Add a session schedule store to this context.
1175    pub fn with_schedule_store(mut self, store: Arc<dyn SessionScheduleStore>) -> Self {
1176        self.schedule_store = Some(store);
1177        self
1178    }
1179
1180    /// Add a platform store to this context.
1181    pub fn with_platform_store(
1182        mut self,
1183        store: Arc<dyn crate::platform_store::PlatformStore>,
1184    ) -> Self {
1185        self.platform_store = Some(store);
1186        self
1187    }
1188
1189    /// Add a leased resource store to this context.
1190    pub fn with_leased_resource_store(mut self, store: Arc<dyn LeasedResourceStore>) -> Self {
1191        self.leased_resource_store = Some(store);
1192        self
1193    }
1194
1195    /// Add a session resource registry to this context.
1196    pub fn with_session_resource_registry(
1197        mut self,
1198        registry: Arc<dyn SessionResourceRegistry>,
1199    ) -> Self {
1200        self.session_resource_registry = Some(registry);
1201        self
1202    }
1203
1204    /// Add a memory store backend for persistent cross-session memory.
1205    pub fn with_memory_store(
1206        mut self,
1207        store: Arc<dyn crate::memory_store::MemoryStoreBackend>,
1208    ) -> Self {
1209        self.memory_store = Some(store);
1210        self
1211    }
1212
1213    /// Set org ID for org-scoped operations.
1214    pub fn with_org_id(mut self, org_id: crate::typed_id::OrgId) -> Self {
1215        self.org_id = Some(org_id);
1216        self
1217    }
1218
1219    /// Set the active built-in tool registry on this context.
1220    pub fn with_tool_registry(mut self, registry: Arc<crate::tools::ToolRegistry>) -> Self {
1221        self.tool_registry = Some(registry);
1222        self
1223    }
1224
1225    /// Set the merged network access list for URL filtering.
1226    pub fn with_network_access(
1227        mut self,
1228        network_access: Option<crate::network_access::NetworkAccessList>,
1229    ) -> Self {
1230        self.network_access = network_access;
1231        self
1232    }
1233
1234    /// Set the internal payment authority for paid capability operations.
1235    pub fn with_payment_authority(mut self, authority: Arc<dyn PaymentAuthority>) -> Self {
1236        self.payment_authority = Some(authority);
1237        self
1238    }
1239
1240    /// Emit a `tool.progress` event if an event emitter and context are available.
1241    ///
1242    /// This is a best-effort helper: failures are logged but not propagated,
1243    /// so tools never fail just because a progress event couldn't be sent.
1244    pub async fn emit_progress(&self, tool_name: &str, message: &str) {
1245        let (Some(emitter), Some(ctx), Some(call_id)) =
1246            (&self.event_emitter, &self.event_context, &self.tool_call_id)
1247        else {
1248            return;
1249        };
1250        if let Err(e) = emitter
1251            .emit(EventRequest::new(
1252                self.session_id,
1253                ctx.clone(),
1254                crate::events::ToolProgressData {
1255                    tool_call_id: call_id.clone(),
1256                    tool_name: tool_name.to_string(),
1257                    message: message.to_string(),
1258                    display_name: None,
1259                },
1260            ))
1261            .await
1262        {
1263            tracing::debug!(
1264                tool_call_id = call_id,
1265                tool_name,
1266                error = %e,
1267                "Failed to emit tool.progress event"
1268            );
1269        }
1270    }
1271
1272    /// Emit a `tool.output.delta` event if an event emitter and context are available.
1273    ///
1274    /// Streams incremental output chunks (e.g., stdout/stderr lines) for live
1275    /// rendering in UI and CLI. Best-effort: failures are logged, not propagated.
1276    pub async fn emit_tool_output(&self, tool_name: &str, delta: &str, stream: &str) {
1277        let (Some(emitter), Some(ctx), Some(call_id)) =
1278            (&self.event_emitter, &self.event_context, &self.tool_call_id)
1279        else {
1280            return;
1281        };
1282        if let Err(e) = emitter
1283            .emit(EventRequest::new(
1284                self.session_id,
1285                ctx.clone(),
1286                crate::events::ToolOutputDeltaData {
1287                    tool_call_id: call_id.clone(),
1288                    tool_name: tool_name.to_string(),
1289                    delta: delta.to_string(),
1290                    stream: stream.to_string(),
1291                },
1292            ))
1293            .await
1294        {
1295            tracing::debug!(
1296                tool_call_id = call_id,
1297                tool_name,
1298                error = %e,
1299                "Failed to emit tool.output.delta event"
1300            );
1301        }
1302    }
1303}
1304
1305impl std::fmt::Debug for ToolContext {
1306    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1307        f.debug_struct("ToolContext")
1308            .field("session_id", &self.session_id)
1309            .field("file_store", &self.file_store.is_some())
1310            .field("storage_store", &self.storage_store.is_some())
1311            .field("image_store", &self.image_store.is_some())
1312            .field(
1313                "provider_credential_store",
1314                &self.provider_credential_store.is_some(),
1315            )
1316            .field("utility_llm_service", &self.utility_llm_service.is_some())
1317            .field("egress_service", &self.egress_service.is_some())
1318            .field("sqldb_store", &self.sqldb_store.is_some())
1319            .field("message_retriever", &self.message_retriever.is_some())
1320            .field("session_store", &self.session_store.is_some())
1321            .field("session_mutator", &self.session_mutator.is_some())
1322            .field("agent_store", &self.agent_store.is_some())
1323            .field("connection_resolver", &self.connection_resolver.is_some())
1324            .field("schedule_store", &self.schedule_store.is_some())
1325            .field("platform_store", &self.platform_store.is_some())
1326            .field(
1327                "leased_resource_store",
1328                &self.leased_resource_store.is_some(),
1329            )
1330            .field("event_emitter", &self.event_emitter.is_some())
1331            .field("tool_registry", &self.tool_registry.is_some())
1332            .field("memory_store", &self.memory_store.is_some())
1333            .field("payment_authority", &self.payment_authority.is_some())
1334            .field("org_id", &self.org_id)
1335            .finish()
1336    }
1337}
1338
1339// ============================================================================
1340// EventEmitter - For emitting events
1341// ============================================================================
1342
1343use crate::events::{Event, EventRequest};
1344
1345/// Trait for emitting events following the standard event protocol
1346///
1347/// Implementations can:
1348/// - Store events in a database
1349/// - Keep events in memory for testing
1350/// - Stream events via SSE/WebSocket
1351/// - Log events for debugging
1352///
1353/// Events follow a consistent schema: id, type, ts, context, data.
1354/// See specs/events.md for the full event protocol specification.
1355#[async_trait]
1356pub trait EventEmitter: Send + Sync {
1357    /// Emit an event request
1358    ///
1359    /// Takes an EventRequest (without id/sequence) and returns the stored Event
1360    /// with id and sequence assigned by the storage layer.
1361    async fn emit(&self, request: EventRequest) -> Result<Event>;
1362}
1363
1364/// Blanket impl: `Arc<E>` delegates to the inner emitter.
1365#[async_trait]
1366impl<E: EventEmitter + ?Sized> EventEmitter for Arc<E> {
1367    async fn emit(&self, request: EventRequest) -> Result<Event> {
1368        (**self).emit(request).await
1369    }
1370}
1371
1372/// No-op event emitter for when event emission is not needed
1373///
1374/// This is useful for testing or when event observability is disabled.
1375#[derive(Debug, Clone, Default)]
1376pub struct NoopEventEmitter;
1377
1378#[async_trait]
1379impl EventEmitter for NoopEventEmitter {
1380    async fn emit(&self, request: EventRequest) -> Result<Event> {
1381        // Return a dummy event with sequence 0
1382        Ok(request.into_event(crate::typed_id::EventId::new(), 0))
1383    }
1384}
1385
1386// Note: EventListener trait has been moved to event_listeners.rs module.
1387// Use `everruns_core::EventListener` or `everruns_core::event_listeners::EventListener`.
1388
1389// ============================================================================
1390// ImageResolver - For resolving image_file content to actual image data
1391// ============================================================================
1392
1393/// Resolved image data for LLM consumption
1394///
1395/// This struct contains the actual image data in a format suitable for
1396/// sending to LLM providers. Both OpenAI and Anthropic accept base64-encoded
1397/// images with media type information.
1398#[derive(Debug, Clone)]
1399pub struct ResolvedImage {
1400    /// Base64-encoded image data (without data URL prefix)
1401    pub base64: String,
1402    /// MIME type (e.g., "image/png", "image/jpeg")
1403    pub media_type: String,
1404}
1405
1406impl ResolvedImage {
1407    /// Create a new resolved image
1408    pub fn new(base64: impl Into<String>, media_type: impl Into<String>) -> Self {
1409        Self {
1410            base64: base64.into(),
1411            media_type: media_type.into(),
1412        }
1413    }
1414
1415    /// Convert to a data URL suitable for OpenAI Vision API
1416    ///
1417    /// Format: `data:{media_type};base64,{base64_data}`
1418    pub fn to_data_url(&self) -> String {
1419        format!("data:{};base64,{}", self.media_type, self.base64)
1420    }
1421}
1422
1423/// Trait for resolving image_file content parts to actual image data
1424///
1425/// When building LLM messages, `image_file` content parts contain only
1426/// a reference (UUID) to an uploaded image. This trait allows resolving
1427/// those references to actual image data.
1428///
1429/// # Provider-specific formatting
1430///
1431/// The resolved image data is then converted to provider-specific formats:
1432///
1433/// **OpenAI Vision:**
1434/// ```json
1435/// {
1436///   "type": "image_url",
1437///   "image_url": { "url": "data:image/png;base64,..." }
1438/// }
1439/// ```
1440///
1441/// **Anthropic Vision:**
1442/// ```json
1443/// {
1444///   "type": "image",
1445///   "source": { "type": "base64", "media_type": "image/png", "data": "..." }
1446/// }
1447/// ```
1448///
1449/// # Implementation notes
1450///
1451/// Implementations should:
1452/// - Fetch image data from storage (database, S3, etc.)
1453/// - Return base64-encoded data with media type
1454/// - Handle missing images gracefully (return None)
1455#[async_trait]
1456pub trait ImageResolver: Send + Sync {
1457    /// Resolve an image_file reference to actual image data
1458    ///
1459    /// Returns `None` if the image is not found.
1460    async fn resolve_image(&self, image_id: Uuid) -> Result<Option<ResolvedImage>>;
1461}
1462
1463// ============================================================================
1464// Tests
1465// ============================================================================
1466
1467#[cfg(test)]
1468mod tests {
1469    use super::*;
1470
1471    #[test]
1472    fn test_resolved_image_new() {
1473        let image = ResolvedImage::new("SGVsbG8=", "image/png");
1474        assert_eq!(image.base64, "SGVsbG8=");
1475        assert_eq!(image.media_type, "image/png");
1476    }
1477
1478    #[test]
1479    fn test_resolved_image_to_data_url() {
1480        let image = ResolvedImage::new("SGVsbG8=", "image/png");
1481        let data_url = image.to_data_url();
1482        assert_eq!(data_url, "data:image/png;base64,SGVsbG8=");
1483    }
1484
1485    #[test]
1486    fn test_resolved_image_jpeg() {
1487        let image = ResolvedImage::new("base64data", "image/jpeg");
1488        let data_url = image.to_data_url();
1489        assert!(data_url.starts_with("data:image/jpeg;base64,"));
1490    }
1491}