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 session SQL database store
859    pub sqldb_store: Option<SessionSqlDbStoreRef>,
860
861    /// Optional message retriever for tools that need conversation history access
862    pub message_retriever: Option<Arc<dyn crate::message_retriever::MessageRetriever>>,
863
864    /// Optional session store for tools that need session metadata access.
865    pub session_store: Option<Arc<dyn SessionStore>>,
866
867    /// Optional session mutator for tools that need to update session metadata.
868    pub session_mutator: Option<Arc<dyn SessionMutator>>,
869
870    /// Optional agent store for tools that need agent metadata access.
871    pub agent_store: Option<Arc<dyn AgentStore>>,
872
873    /// Optional resolver for user connection tokens (lazy GitHub token lookup, etc.)
874    pub connection_resolver: Option<Arc<dyn UserConnectionResolver>>,
875
876    /// Optional session schedule store for scheduling tools.
877    pub schedule_store: Option<Arc<dyn SessionScheduleStore>>,
878
879    /// Optional platform store for org-level management tools.
880    pub platform_store: Option<Arc<dyn crate::platform_store::PlatformStore>>,
881    /// Optional leased resource store for lifecycle-managed provider resources.
882    pub leased_resource_store: Option<Arc<dyn LeasedResourceStore>>,
883
884    /// Optional session resource registry — generic registry of active resources.
885    pub session_resource_registry: Option<Arc<dyn SessionResourceRegistry>>,
886
887    /// Optional event emitter for tools that need to stream progress updates.
888    /// When set, tools can emit `tool.progress` events during execution.
889    pub event_emitter: Option<Arc<dyn EventEmitter>>,
890
891    /// Event context for correlating progress events with the current tool call.
892    /// Set by ActAtom when constructing the ToolContext.
893    pub event_context: Option<crate::events::EventContext>,
894
895    /// The tool call ID for the current execution (set by ActAtom).
896    /// Used by tools to emit correlated progress events.
897    pub tool_call_id: Option<String>,
898    /// Optional capability registry for blueprint lookups.
899    pub capability_registry: Option<crate::capabilities::CapabilityRegistry>,
900
901    /// Optional registry of active built-in tools for meta-tools such as
902    /// `spawn_background` that need to inspect or delegate to sibling tools.
903    pub tool_registry: Option<Arc<crate::tools::ToolRegistry>>,
904
905    /// Optional memory store backend for persistent cross-session memory.
906    pub memory_store: Option<Arc<dyn crate::memory_store::MemoryStoreBackend>>,
907
908    /// Optional org ID for org-scoped operations (memory stores, etc.).
909    pub org_id: Option<crate::typed_id::OrgId>,
910
911    /// Merged network access list (harness ∩ agent ∩ session).
912    /// When set, tools that make HTTP requests must check URLs against this list.
913    pub network_access: Option<crate::network_access::NetworkAccessList>,
914
915    /// Resolved locale for localized tool behavior (BCP 47, e.g. `uk-UA`).
916    /// When set, tools that support localization use this to produce
917    /// locale-appropriate descriptions, error messages, and prompts.
918    pub locale: Option<String>,
919
920    /// Optional budget checker for the check_budget tool.
921    pub budget_checker: Option<Arc<dyn BudgetChecker>>,
922
923    /// Optional internal payment authority for paid capability tools.
924    pub payment_authority: Option<Arc<dyn PaymentAuthority>>,
925}
926
927impl ToolContext {
928    /// Create a new tool context with just a session ID
929    pub fn new(session_id: SessionId) -> Self {
930        Self {
931            session_id,
932            file_store: None,
933            storage_store: None,
934            image_store: None,
935            provider_credential_store: None,
936            sqldb_store: None,
937            message_retriever: None,
938            session_store: None,
939            session_mutator: None,
940            agent_store: None,
941            connection_resolver: None,
942            schedule_store: None,
943            platform_store: None,
944            leased_resource_store: None,
945            session_resource_registry: None,
946            event_emitter: None,
947            event_context: None,
948            tool_call_id: None,
949            capability_registry: None,
950            tool_registry: None,
951            memory_store: None,
952            org_id: None,
953            network_access: None,
954            locale: None,
955            budget_checker: None,
956            payment_authority: None,
957        }
958    }
959
960    /// Create a context with a file store
961    pub fn with_file_store(session_id: SessionId, file_store: Arc<dyn SessionFileSystem>) -> Self {
962        Self {
963            session_id,
964            file_store: Some(file_store),
965            storage_store: None,
966            image_store: None,
967            provider_credential_store: None,
968            sqldb_store: None,
969            message_retriever: None,
970            session_store: None,
971            session_mutator: None,
972            agent_store: None,
973            connection_resolver: None,
974            schedule_store: None,
975            platform_store: None,
976            leased_resource_store: None,
977            session_resource_registry: None,
978            event_emitter: None,
979            event_context: None,
980            tool_call_id: None,
981            capability_registry: None,
982            tool_registry: None,
983            memory_store: None,
984            org_id: None,
985            network_access: None,
986            locale: None,
987            budget_checker: None,
988            payment_authority: None,
989        }
990    }
991
992    /// Create a context with a storage store
993    pub fn with_storage_store(
994        session_id: SessionId,
995        storage_store: Arc<dyn SessionStorageStore>,
996    ) -> Self {
997        Self {
998            session_id,
999            file_store: None,
1000            storage_store: Some(storage_store),
1001            image_store: None,
1002            provider_credential_store: None,
1003            sqldb_store: None,
1004            message_retriever: None,
1005            session_store: None,
1006            session_mutator: None,
1007            agent_store: None,
1008            connection_resolver: None,
1009            schedule_store: None,
1010            platform_store: None,
1011            leased_resource_store: None,
1012            session_resource_registry: None,
1013            event_emitter: None,
1014            event_context: None,
1015            tool_call_id: None,
1016            capability_registry: None,
1017            tool_registry: None,
1018            memory_store: None,
1019            org_id: None,
1020            network_access: None,
1021            locale: None,
1022            budget_checker: None,
1023            payment_authority: None,
1024        }
1025    }
1026
1027    /// Create a context with both file store and storage store
1028    pub fn with_stores(
1029        session_id: SessionId,
1030        file_store: Arc<dyn SessionFileSystem>,
1031        storage_store: Arc<dyn SessionStorageStore>,
1032    ) -> Self {
1033        Self {
1034            session_id,
1035            file_store: Some(file_store),
1036            storage_store: Some(storage_store),
1037            sqldb_store: None,
1038            image_store: None,
1039            provider_credential_store: None,
1040            message_retriever: None,
1041            session_store: None,
1042            session_mutator: None,
1043            agent_store: None,
1044            connection_resolver: None,
1045            schedule_store: None,
1046            platform_store: None,
1047            leased_resource_store: None,
1048            session_resource_registry: None,
1049            event_emitter: None,
1050            event_context: None,
1051            tool_call_id: None,
1052            capability_registry: None,
1053            tool_registry: None,
1054            memory_store: None,
1055            org_id: None,
1056            network_access: None,
1057            locale: None,
1058            budget_checker: None,
1059            payment_authority: None,
1060        }
1061    }
1062
1063    /// Add a SQL database store to this context
1064    pub fn with_sqldb_store(mut self, sqldb_store: SessionSqlDbStoreRef) -> Self {
1065        self.sqldb_store = Some(sqldb_store);
1066        self
1067    }
1068
1069    /// Add a message retriever to this context
1070    pub fn with_message_retriever(
1071        mut self,
1072        retriever: Arc<dyn crate::message_retriever::MessageRetriever>,
1073    ) -> Self {
1074        self.message_retriever = Some(retriever);
1075        self
1076    }
1077
1078    /// Add a session store to this context.
1079    pub fn with_session_store(mut self, store: Arc<dyn SessionStore>) -> Self {
1080        self.session_store = Some(store);
1081        self
1082    }
1083
1084    /// Add a session mutator to this context.
1085    pub fn with_session_mutator(mut self, mutator: Arc<dyn SessionMutator>) -> Self {
1086        self.session_mutator = Some(mutator);
1087        self
1088    }
1089
1090    /// Add an agent store to this context.
1091    pub fn with_agent_store(mut self, store: Arc<dyn AgentStore>) -> Self {
1092        self.agent_store = Some(store);
1093        self
1094    }
1095
1096    /// Add a connection resolver to this context
1097    pub fn with_connection_resolver(mut self, resolver: Arc<dyn UserConnectionResolver>) -> Self {
1098        self.connection_resolver = Some(resolver);
1099        self
1100    }
1101
1102    /// Create a context with an image artifact store.
1103    pub fn with_image_store(
1104        session_id: SessionId,
1105        image_store: Arc<dyn ImageArtifactStore>,
1106    ) -> Self {
1107        Self {
1108            session_id,
1109            file_store: None,
1110            storage_store: None,
1111            image_store: Some(image_store),
1112            provider_credential_store: None,
1113            sqldb_store: None,
1114            message_retriever: None,
1115            session_store: None,
1116            session_mutator: None,
1117            agent_store: None,
1118            connection_resolver: None,
1119            schedule_store: None,
1120            platform_store: None,
1121            leased_resource_store: None,
1122            session_resource_registry: None,
1123            event_emitter: None,
1124            event_context: None,
1125            tool_call_id: None,
1126            capability_registry: None,
1127            tool_registry: None,
1128            memory_store: None,
1129            org_id: None,
1130            network_access: None,
1131            locale: None,
1132            budget_checker: None,
1133            payment_authority: None,
1134        }
1135    }
1136
1137    /// Set the provider credential store on this context.
1138    pub fn with_provider_credential_store(
1139        mut self,
1140        store: Arc<dyn ProviderCredentialStore>,
1141    ) -> Self {
1142        self.provider_credential_store = Some(store);
1143        self
1144    }
1145
1146    /// Add a session schedule store to this context.
1147    pub fn with_schedule_store(mut self, store: Arc<dyn SessionScheduleStore>) -> Self {
1148        self.schedule_store = Some(store);
1149        self
1150    }
1151
1152    /// Add a platform store to this context.
1153    pub fn with_platform_store(
1154        mut self,
1155        store: Arc<dyn crate::platform_store::PlatformStore>,
1156    ) -> Self {
1157        self.platform_store = Some(store);
1158        self
1159    }
1160
1161    /// Add a leased resource store to this context.
1162    pub fn with_leased_resource_store(mut self, store: Arc<dyn LeasedResourceStore>) -> Self {
1163        self.leased_resource_store = Some(store);
1164        self
1165    }
1166
1167    /// Add a session resource registry to this context.
1168    pub fn with_session_resource_registry(
1169        mut self,
1170        registry: Arc<dyn SessionResourceRegistry>,
1171    ) -> Self {
1172        self.session_resource_registry = Some(registry);
1173        self
1174    }
1175
1176    /// Add a memory store backend for persistent cross-session memory.
1177    pub fn with_memory_store(
1178        mut self,
1179        store: Arc<dyn crate::memory_store::MemoryStoreBackend>,
1180    ) -> Self {
1181        self.memory_store = Some(store);
1182        self
1183    }
1184
1185    /// Set org ID for org-scoped operations.
1186    pub fn with_org_id(mut self, org_id: crate::typed_id::OrgId) -> Self {
1187        self.org_id = Some(org_id);
1188        self
1189    }
1190
1191    /// Set the active built-in tool registry on this context.
1192    pub fn with_tool_registry(mut self, registry: Arc<crate::tools::ToolRegistry>) -> Self {
1193        self.tool_registry = Some(registry);
1194        self
1195    }
1196
1197    /// Set the merged network access list for URL filtering.
1198    pub fn with_network_access(
1199        mut self,
1200        network_access: Option<crate::network_access::NetworkAccessList>,
1201    ) -> Self {
1202        self.network_access = network_access;
1203        self
1204    }
1205
1206    /// Set the internal payment authority for paid capability operations.
1207    pub fn with_payment_authority(mut self, authority: Arc<dyn PaymentAuthority>) -> Self {
1208        self.payment_authority = Some(authority);
1209        self
1210    }
1211
1212    /// Emit a `tool.progress` event if an event emitter and context are available.
1213    ///
1214    /// This is a best-effort helper: failures are logged but not propagated,
1215    /// so tools never fail just because a progress event couldn't be sent.
1216    pub async fn emit_progress(&self, tool_name: &str, message: &str) {
1217        let (Some(emitter), Some(ctx), Some(call_id)) =
1218            (&self.event_emitter, &self.event_context, &self.tool_call_id)
1219        else {
1220            return;
1221        };
1222        if let Err(e) = emitter
1223            .emit(EventRequest::new(
1224                self.session_id,
1225                ctx.clone(),
1226                crate::events::ToolProgressData {
1227                    tool_call_id: call_id.clone(),
1228                    tool_name: tool_name.to_string(),
1229                    message: message.to_string(),
1230                    display_name: None,
1231                },
1232            ))
1233            .await
1234        {
1235            tracing::debug!(
1236                tool_call_id = call_id,
1237                tool_name,
1238                error = %e,
1239                "Failed to emit tool.progress event"
1240            );
1241        }
1242    }
1243
1244    /// Emit a `tool.output.delta` event if an event emitter and context are available.
1245    ///
1246    /// Streams incremental output chunks (e.g., stdout/stderr lines) for live
1247    /// rendering in UI and CLI. Best-effort: failures are logged, not propagated.
1248    pub async fn emit_tool_output(&self, tool_name: &str, delta: &str, stream: &str) {
1249        let (Some(emitter), Some(ctx), Some(call_id)) =
1250            (&self.event_emitter, &self.event_context, &self.tool_call_id)
1251        else {
1252            return;
1253        };
1254        if let Err(e) = emitter
1255            .emit(EventRequest::new(
1256                self.session_id,
1257                ctx.clone(),
1258                crate::events::ToolOutputDeltaData {
1259                    tool_call_id: call_id.clone(),
1260                    tool_name: tool_name.to_string(),
1261                    delta: delta.to_string(),
1262                    stream: stream.to_string(),
1263                },
1264            ))
1265            .await
1266        {
1267            tracing::debug!(
1268                tool_call_id = call_id,
1269                tool_name,
1270                error = %e,
1271                "Failed to emit tool.output.delta event"
1272            );
1273        }
1274    }
1275}
1276
1277impl std::fmt::Debug for ToolContext {
1278    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1279        f.debug_struct("ToolContext")
1280            .field("session_id", &self.session_id)
1281            .field("file_store", &self.file_store.is_some())
1282            .field("storage_store", &self.storage_store.is_some())
1283            .field("image_store", &self.image_store.is_some())
1284            .field(
1285                "provider_credential_store",
1286                &self.provider_credential_store.is_some(),
1287            )
1288            .field("sqldb_store", &self.sqldb_store.is_some())
1289            .field("message_retriever", &self.message_retriever.is_some())
1290            .field("session_store", &self.session_store.is_some())
1291            .field("session_mutator", &self.session_mutator.is_some())
1292            .field("agent_store", &self.agent_store.is_some())
1293            .field("connection_resolver", &self.connection_resolver.is_some())
1294            .field("schedule_store", &self.schedule_store.is_some())
1295            .field("platform_store", &self.platform_store.is_some())
1296            .field(
1297                "leased_resource_store",
1298                &self.leased_resource_store.is_some(),
1299            )
1300            .field("event_emitter", &self.event_emitter.is_some())
1301            .field("tool_registry", &self.tool_registry.is_some())
1302            .field("memory_store", &self.memory_store.is_some())
1303            .field("payment_authority", &self.payment_authority.is_some())
1304            .field("org_id", &self.org_id)
1305            .finish()
1306    }
1307}
1308
1309// ============================================================================
1310// EventEmitter - For emitting events
1311// ============================================================================
1312
1313use crate::events::{Event, EventRequest};
1314
1315/// Trait for emitting events following the standard event protocol
1316///
1317/// Implementations can:
1318/// - Store events in a database
1319/// - Keep events in memory for testing
1320/// - Stream events via SSE/WebSocket
1321/// - Log events for debugging
1322///
1323/// Events follow a consistent schema: id, type, ts, context, data.
1324/// See specs/events.md for the full event protocol specification.
1325#[async_trait]
1326pub trait EventEmitter: Send + Sync {
1327    /// Emit an event request
1328    ///
1329    /// Takes an EventRequest (without id/sequence) and returns the stored Event
1330    /// with id and sequence assigned by the storage layer.
1331    async fn emit(&self, request: EventRequest) -> Result<Event>;
1332}
1333
1334/// Blanket impl: `Arc<E>` delegates to the inner emitter.
1335#[async_trait]
1336impl<E: EventEmitter + ?Sized> EventEmitter for Arc<E> {
1337    async fn emit(&self, request: EventRequest) -> Result<Event> {
1338        (**self).emit(request).await
1339    }
1340}
1341
1342/// No-op event emitter for when event emission is not needed
1343///
1344/// This is useful for testing or when event observability is disabled.
1345#[derive(Debug, Clone, Default)]
1346pub struct NoopEventEmitter;
1347
1348#[async_trait]
1349impl EventEmitter for NoopEventEmitter {
1350    async fn emit(&self, request: EventRequest) -> Result<Event> {
1351        // Return a dummy event with sequence 0
1352        Ok(request.into_event(crate::typed_id::EventId::new(), 0))
1353    }
1354}
1355
1356// Note: EventListener trait has been moved to event_listeners.rs module.
1357// Use `everruns_core::EventListener` or `everruns_core::event_listeners::EventListener`.
1358
1359// ============================================================================
1360// ImageResolver - For resolving image_file content to actual image data
1361// ============================================================================
1362
1363/// Resolved image data for LLM consumption
1364///
1365/// This struct contains the actual image data in a format suitable for
1366/// sending to LLM providers. Both OpenAI and Anthropic accept base64-encoded
1367/// images with media type information.
1368#[derive(Debug, Clone)]
1369pub struct ResolvedImage {
1370    /// Base64-encoded image data (without data URL prefix)
1371    pub base64: String,
1372    /// MIME type (e.g., "image/png", "image/jpeg")
1373    pub media_type: String,
1374}
1375
1376impl ResolvedImage {
1377    /// Create a new resolved image
1378    pub fn new(base64: impl Into<String>, media_type: impl Into<String>) -> Self {
1379        Self {
1380            base64: base64.into(),
1381            media_type: media_type.into(),
1382        }
1383    }
1384
1385    /// Convert to a data URL suitable for OpenAI Vision API
1386    ///
1387    /// Format: `data:{media_type};base64,{base64_data}`
1388    pub fn to_data_url(&self) -> String {
1389        format!("data:{};base64,{}", self.media_type, self.base64)
1390    }
1391}
1392
1393/// Trait for resolving image_file content parts to actual image data
1394///
1395/// When building LLM messages, `image_file` content parts contain only
1396/// a reference (UUID) to an uploaded image. This trait allows resolving
1397/// those references to actual image data.
1398///
1399/// # Provider-specific formatting
1400///
1401/// The resolved image data is then converted to provider-specific formats:
1402///
1403/// **OpenAI Vision:**
1404/// ```json
1405/// {
1406///   "type": "image_url",
1407///   "image_url": { "url": "data:image/png;base64,..." }
1408/// }
1409/// ```
1410///
1411/// **Anthropic Vision:**
1412/// ```json
1413/// {
1414///   "type": "image",
1415///   "source": { "type": "base64", "media_type": "image/png", "data": "..." }
1416/// }
1417/// ```
1418///
1419/// # Implementation notes
1420///
1421/// Implementations should:
1422/// - Fetch image data from storage (database, S3, etc.)
1423/// - Return base64-encoded data with media type
1424/// - Handle missing images gracefully (return None)
1425#[async_trait]
1426pub trait ImageResolver: Send + Sync {
1427    /// Resolve an image_file reference to actual image data
1428    ///
1429    /// Returns `None` if the image is not found.
1430    async fn resolve_image(&self, image_id: Uuid) -> Result<Option<ResolvedImage>>;
1431}
1432
1433// ============================================================================
1434// Tests
1435// ============================================================================
1436
1437#[cfg(test)]
1438mod tests {
1439    use super::*;
1440
1441    #[test]
1442    fn test_resolved_image_new() {
1443        let image = ResolvedImage::new("SGVsbG8=", "image/png");
1444        assert_eq!(image.base64, "SGVsbG8=");
1445        assert_eq!(image.media_type, "image/png");
1446    }
1447
1448    #[test]
1449    fn test_resolved_image_to_data_url() {
1450        let image = ResolvedImage::new("SGVsbG8=", "image/png");
1451        let data_url = image.to_data_url();
1452        assert_eq!(data_url, "data:image/png;base64,SGVsbG8=");
1453    }
1454
1455    #[test]
1456    fn test_resolved_image_jpeg() {
1457        let image = ResolvedImage::new("base64data", "image/jpeg");
1458        let data_url = image.to_data_url();
1459        assert!(data_url.starts_with("data:image/jpeg;base64,"));
1460    }
1461}