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.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}