Skip to main content

stygian_graph/adapters/
agent_source.rs

1//! Agent source adapter — wraps an [`AIProvider`] as a pipeline data source.
2//!
3//! Implements [`AgentSourcePort`] and [`ScrapingService`] so that an LLM can
4//! be used as a node in the DAG pipeline.  Unlike the AI adapters (which
5//! *extract* structured data from existing content), this adapter *generates*
6//! content by executing a user-supplied prompt.
7//!
8//! # Example
9//!
10//! ```no_run
11//! use stygian_graph::adapters::agent_source::AgentSource;
12//! use stygian_graph::adapters::mock_ai::MockAIProvider;
13//! use stygian_graph::ports::agent_source::{AgentSourcePort, AgentRequest};
14//! use serde_json::json;
15//! use std::sync::Arc;
16//!
17//! # async fn example() {
18//! let provider = Arc::new(MockAIProvider);
19//! let agent = AgentSource::new(provider);
20//! let resp = agent.invoke(AgentRequest {
21//!     prompt: "Summarise the data".into(),
22//!     context: Some("raw data here".into()),
23//!     parameters: json!({}),
24//! }).await.unwrap();
25//! println!("{}", resp.content);
26//! # }
27//! ```
28
29use std::sync::Arc;
30
31use async_trait::async_trait;
32use serde_json::{Value, json};
33
34use crate::domain::error::Result;
35use crate::ports::agent_source::{AgentRequest, AgentResponse, AgentSourcePort};
36use crate::ports::{AIProvider, ScrapingService, ServiceInput, ServiceOutput};
37
38// ─────────────────────────────────────────────────────────────────────────────
39// AgentSource
40// ─────────────────────────────────────────────────────────────────────────────
41
42/// Adapter: LLM agent as a pipeline data source.
43///
44/// Wraps any [`AIProvider`] and exposes it through [`AgentSourcePort`] and
45/// [`ScrapingService`] for integration into DAG pipelines.
46pub struct AgentSource {
47    provider: Arc<dyn AIProvider>,
48}
49
50impl AgentSource {
51    /// Create a new agent source backed by the given AI provider.
52    ///
53    /// # Arguments
54    ///
55    /// * `provider` - An `Arc`-wrapped [`AIProvider`] implementation.
56    ///
57    /// # Example
58    ///
59    /// ```no_run
60    /// use stygian_graph::adapters::agent_source::AgentSource;
61    /// use stygian_graph::adapters::mock_ai::MockAIProvider;
62    /// use std::sync::Arc;
63    ///
64    /// let source = AgentSource::new(Arc::new(MockAIProvider));
65    /// ```
66    #[must_use]
67    pub fn new(provider: Arc<dyn AIProvider>) -> Self {
68        Self { provider }
69    }
70}
71
72// ─────────────────────────────────────────────────────────────────────────────
73// AgentSourcePort
74// ─────────────────────────────────────────────────────────────────────────────
75
76#[async_trait]
77impl AgentSourcePort for AgentSource {
78    async fn invoke(&self, request: AgentRequest) -> Result<AgentResponse> {
79        // Build a combined prompt+context string for the AI provider
80        let content = match &request.context {
81            Some(ctx) => format!("{}\n\n---\n\n{ctx}", request.prompt),
82            None => request.prompt.clone(),
83        };
84
85        // Use the provider's extract method with the parameters as schema
86        // (the provider returns JSON matching the "schema", which here is the
87        // caller's parameters object — giving the provider guidance on what to
88        // generate).
89        let schema = if request.parameters.is_null()
90            || request.parameters.is_object()
91                && request.parameters.as_object().is_some_and(|m| m.is_empty())
92        {
93            json!({"type": "object", "properties": {"response": {"type": "string"}}})
94        } else {
95            request.parameters.clone()
96        };
97
98        let result = self.provider.extract(content, schema).await?;
99
100        // Extract a textual response from the provider's output
101        let content_text = if let Some(s) = result.get("response").and_then(Value::as_str) {
102            s.to_string()
103        } else {
104            serde_json::to_string(&result).unwrap_or_default()
105        };
106
107        Ok(AgentResponse {
108            content: content_text,
109            metadata: json!({
110                "provider": self.provider.name(),
111                "raw_output": result,
112            }),
113        })
114    }
115
116    fn source_name(&self) -> &str {
117        "agent"
118    }
119}
120
121// ─────────────────────────────────────────────────────────────────────────────
122// ScrapingService (DAG integration)
123// ─────────────────────────────────────────────────────────────────────────────
124
125#[async_trait]
126impl ScrapingService for AgentSource {
127    /// Invoke the LLM agent with prompt data from the pipeline.
128    ///
129    /// Expected params:
130    /// ```json
131    /// { "prompt": "Summarise this page", "parameters": {} }
132    /// ```
133    ///
134    /// The `input.url` field is ignored; the prompt and optional upstream data
135    /// are passed via `params`.
136    async fn execute(&self, input: ServiceInput) -> Result<ServiceOutput> {
137        let prompt = input.params["prompt"]
138            .as_str()
139            .unwrap_or("Process the following data")
140            .to_string();
141
142        let context = input.params["context"].as_str().map(String::from);
143        let parameters = input.params.get("parameters").cloned().unwrap_or(json!({}));
144
145        let request = AgentRequest {
146            prompt,
147            context,
148            parameters,
149        };
150
151        let response = self.invoke(request).await?;
152
153        Ok(ServiceOutput {
154            data: response.content,
155            metadata: response.metadata,
156        })
157    }
158
159    fn name(&self) -> &'static str {
160        "agent"
161    }
162}
163
164// ─────────────────────────────────────────────────────────────────────────────
165// Tests
166// ─────────────────────────────────────────────────────────────────────────────
167
168#[cfg(test)]
169mod tests {
170    use super::*;
171    use crate::adapters::mock_ai::MockAIProvider;
172
173    fn make_agent() -> AgentSource {
174        AgentSource::new(Arc::new(MockAIProvider))
175    }
176
177    #[tokio::test]
178    async fn invoke_returns_response() {
179        let agent = make_agent();
180        let req = AgentRequest {
181            prompt: "Say hello".into(),
182            context: None,
183            parameters: json!({}),
184        };
185        let resp = agent.invoke(req).await.unwrap();
186        // MockAIProvider returns {"mock": true, ...} so content will be the
187        // JSON serialisation of the full output (no "response" key).
188        assert!(!resp.content.is_empty());
189        assert_eq!(resp.metadata["provider"].as_str(), Some("mock-ai"),);
190    }
191
192    #[tokio::test]
193    async fn invoke_with_context() {
194        let agent = make_agent();
195        let req = AgentRequest {
196            prompt: "Summarise".into(),
197            context: Some("some article text".into()),
198            parameters: json!({}),
199        };
200        let resp = agent.invoke(req).await.unwrap();
201        assert!(!resp.content.is_empty());
202    }
203
204    #[tokio::test]
205    async fn scraping_service_execute() {
206        let agent = make_agent();
207        let input = ServiceInput {
208            url: String::new(),
209            params: json!({
210                "prompt": "Generate a summary",
211            }),
212        };
213        let output = agent.execute(input).await.unwrap();
214        assert!(!output.data.is_empty());
215        assert_eq!(output.metadata["provider"].as_str(), Some("mock-ai"));
216    }
217
218    #[test]
219    fn source_name() {
220        let agent = make_agent();
221        assert_eq!(AgentSourcePort::source_name(&agent), "agent");
222    }
223
224    #[test]
225    fn service_name() {
226        let agent = make_agent();
227        assert_eq!(ScrapingService::name(&agent), "agent");
228    }
229}