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}