Skip to main content

stygian_graph/adapters/
agent_source.rs

1//! Agent source adapter — wraps an [`AIProvider`](crate::ports::AIProvider) as a pipeline data source.
2//!
3//! Implements [`AgentSourcePort`](crate::ports::agent_source::AgentSourcePort) and [`ScrapingService`](crate::ports::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
92                    .parameters
93                    .as_object()
94                    .is_some_and(serde_json::Map::is_empty)
95        {
96            json!({"type": "object", "properties": {"response": {"type": "string"}}})
97        } else {
98            request.parameters.clone()
99        };
100
101        let result = self.provider.extract(content, schema).await?;
102
103        // Extract a textual response from the provider's output
104        let content_text = result.get("response").and_then(Value::as_str).map_or_else(
105            || serde_json::to_string(&result).unwrap_or_default(),
106            str::to_owned,
107        );
108
109        Ok(AgentResponse {
110            content: content_text,
111            metadata: json!({
112                "provider": self.provider.name(),
113                "raw_output": result,
114            }),
115        })
116    }
117
118    fn source_name(&self) -> &'static str {
119        "agent"
120    }
121}
122
123// ─────────────────────────────────────────────────────────────────────────────
124// ScrapingService (DAG integration)
125// ─────────────────────────────────────────────────────────────────────────────
126
127#[async_trait]
128impl ScrapingService for AgentSource {
129    /// Invoke the LLM agent with prompt data from the pipeline.
130    ///
131    /// Expected params:
132    /// ```json
133    /// { "prompt": "Summarise this page", "parameters": {} }
134    /// ```
135    ///
136    /// The `input.url` field is ignored; the prompt and optional upstream data
137    /// are passed via `params`.
138    async fn execute(&self, input: ServiceInput) -> Result<ServiceOutput> {
139        let prompt = input
140            .params
141            .get("prompt")
142            .and_then(Value::as_str)
143            .unwrap_or("Process the following data")
144            .to_string();
145
146        let context = input
147            .params
148            .get("context")
149            .and_then(Value::as_str)
150            .map(String::from);
151        let parameters = input
152            .params
153            .get("parameters")
154            .cloned()
155            .unwrap_or_else(|| json!({}));
156
157        let request = AgentRequest {
158            prompt,
159            context,
160            parameters,
161        };
162
163        let response = self.invoke(request).await?;
164
165        Ok(ServiceOutput {
166            data: response.content,
167            metadata: response.metadata,
168        })
169    }
170
171    fn name(&self) -> &'static str {
172        "agent"
173    }
174}
175
176// ─────────────────────────────────────────────────────────────────────────────
177// Tests
178// ─────────────────────────────────────────────────────────────────────────────
179
180#[cfg(test)]
181mod tests {
182    use super::*;
183    use crate::adapters::mock_ai::MockAIProvider;
184
185    fn make_agent() -> AgentSource {
186        AgentSource::new(Arc::new(MockAIProvider))
187    }
188
189    #[tokio::test]
190    async fn invoke_returns_response() -> std::result::Result<(), Box<dyn std::error::Error>> {
191        let agent = make_agent();
192        let req = AgentRequest {
193            prompt: "Say hello".into(),
194            context: None,
195            parameters: json!({}),
196        };
197        let resp = agent.invoke(req).await?;
198        // MockAIProvider returns {"mock": true, ...} so content will be the
199        // JSON serialisation of the full output (no "response" key).
200        assert!(!resp.content.is_empty());
201        assert_eq!(
202            resp.metadata.get("provider").and_then(Value::as_str),
203            Some("mock-ai")
204        );
205        Ok(())
206    }
207
208    #[tokio::test]
209    async fn invoke_with_context() -> std::result::Result<(), Box<dyn std::error::Error>> {
210        let agent = make_agent();
211        let req = AgentRequest {
212            prompt: "Summarise".into(),
213            context: Some("some article text".into()),
214            parameters: json!({}),
215        };
216        let resp = agent.invoke(req).await?;
217        assert!(!resp.content.is_empty());
218        Ok(())
219    }
220
221    #[tokio::test]
222    async fn scraping_service_execute() -> std::result::Result<(), Box<dyn std::error::Error>> {
223        let agent = make_agent();
224        let input = ServiceInput {
225            url: String::new(),
226            params: json!({
227                "prompt": "Generate a summary",
228            }),
229        };
230        let output = agent.execute(input).await?;
231        assert!(!output.data.is_empty());
232        assert_eq!(
233            output.metadata.get("provider").and_then(Value::as_str),
234            Some("mock-ai")
235        );
236        Ok(())
237    }
238
239    #[test]
240    fn source_name() {
241        let agent = make_agent();
242        assert_eq!(AgentSourcePort::source_name(&agent), "agent");
243    }
244
245    #[test]
246    fn service_name() {
247        let agent = make_agent();
248        assert_eq!(ScrapingService::name(&agent), "agent");
249    }
250}