Skip to main content

netget/server/rss/
actions.rs

1//! RSS protocol actions implementation
2
3use crate::llm::actions::{
4    protocol_trait::{ActionResult, Protocol, Server},
5    ActionDefinition, Parameter, ParameterDefinition,
6};
7use crate::protocol::log_template::LogTemplate;
8use crate::protocol::{EventType, SpawnContext};
9use crate::state::app_state::AppState;
10use anyhow::{Context, Result};
11use serde_json::json;
12use std::pin::Pin;
13use std::sync::LazyLock;
14
15/// RSS feed requested event - fired when a client requests a feed
16pub static RSS_FEED_REQUESTED_EVENT: LazyLock<EventType> = LazyLock::new(|| {
17    EventType::new(
18        "rss_feed_requested",
19        "A client requested an RSS feed. Answer with generate_rss_feed; the server renders the \
20         XML from the fields you supply.",
21        json!({
22            "type": "generate_rss_feed",
23            "title": "NetGet News",
24            "link": "http://localhost/news.xml",
25            "description": "Latest headlines",
26            "items": [{
27                "title": "First post",
28                "link": "http://localhost/1",
29                "description": "Hello world"
30            }]
31        }),
32    )
33    // The protocol's only sync action, and its only event. `call_llm` advertises the event's
34    // action list rather than get_sync_actions(), so leaving this empty meant every
35    // generate_rss_feed the model produced was rejected as an unknown action.
36    .with_actions(vec![generate_rss_feed_action()])
37    .with_parameters(vec![
38        Parameter {
39            name: "path".to_string(),
40            type_hint: "string".to_string(),
41            description: "Feed path (e.g., /news.xml)".to_string(),
42            required: true,
43        },
44        Parameter {
45            name: "headers".to_string(),
46            type_hint: "object".to_string(),
47            description: "HTTP request headers".to_string(),
48            required: true,
49        },
50    ])
51    .with_log_template(
52        LogTemplate::new()
53            .with_info("RSS request")
54            .with_debug("RSS feed request")
55            .with_trace("RSS: {json_pretty(.)}"),
56    )
57});
58
59/// RSS protocol action handler
60pub struct RssProtocol;
61
62impl RssProtocol {
63    pub fn new() -> Self {
64        Self
65    }
66}
67
68// Implement Protocol trait (common functionality)
69impl Protocol for RssProtocol {
70    fn get_startup_parameters(&self) -> Vec<ParameterDefinition> {
71        Vec::new()
72    }
73
74    fn get_async_actions(&self, _state: &AppState) -> Vec<ActionDefinition> {
75        // RSS has no async actions - feeds are generated on request
76        Vec::new()
77    }
78
79    fn get_sync_actions(&self) -> Vec<ActionDefinition> {
80        vec![generate_rss_feed_action()]
81    }
82
83    fn protocol_name(&self) -> &'static str {
84        "RSS"
85    }
86
87    fn get_event_types(&self) -> Vec<EventType> {
88        vec![RSS_FEED_REQUESTED_EVENT.clone()]
89    }
90
91    fn stack_name(&self) -> &'static str {
92        "ETH>IP>TCP>HTTP>RSS"
93    }
94
95    fn keywords(&self) -> Vec<&'static str> {
96        vec!["rss", "rss server", "feed", "syndication", "via rss"]
97    }
98
99    fn metadata(&self) -> crate::protocol::metadata::ProtocolMetadataV2 {
100        use crate::protocol::metadata::{DevelopmentState, ProtocolMetadataV2};
101
102        ProtocolMetadataV2::builder()
103            .state(DevelopmentState::Beta)
104            .implementation("rss crate for RSS 2.0 XML generation, served over HTTP")
105            .llm_control("Feed content generation (title, items, categories)")
106            .e2e_testing(
107                "feed-rs 2 is the independent reader: it parses the served feed, identifies it \
108                 as FeedType::RSS2, and the test asserts channel title, description and \
109                 language, three entries, the first entry's title and link, that its RFC 2822 \
110                 pub_date parsed into a real timestamp, and its categories \
111                 (tests/server/rss/e2e_test.rs, not #[ignore]d). The parser matters more than \
112                 usual here: this server builds its XML with the `rss` crate, so parsing it \
113                 back with the `rss` crate — which is what this test used to do — proved only \
114                 that one crate round-trips through itself. RSS has no session, so \
115                 fetch-and-parse is the whole protocol; reqwest does the GET, feed-rs is the \
116                 evidence. Not proven: conditional GET / If-Modified-Since, feed \
117                 autodiscovery, Atom.",
118            )
119            .request_only(
120                "RSS is HTTP request/response; a server cannot send a peer anything unprompted",
121            )
122            .build()
123    }
124
125    fn description(&self) -> &'static str {
126        "RSS feed server for web syndication"
127    }
128
129    fn example_prompt(&self) -> &'static str {
130        "Create an RSS feed server on port 8080 serving tech news at /tech.xml with categories"
131    }
132
133    fn group_name(&self) -> &'static str {
134        "Web"
135    }
136
137    fn get_startup_examples(&self) -> crate::llm::actions::StartupExamples {
138        use crate::llm::actions::StartupExamples;
139        use serde_json::json;
140
141        // Deterministic: serve a fixed (empty) RSS feed for every request, no
142        // LLM call.
143        let script = r#"import json, sys
144data = json.load(sys.stdin)
145event = data["event"]
146if data["event_type_id"] == "rss_feed_requested":
147    actions = [{"type": "generate_rss_feed", "title": "NetGet Feed",
148                "link": "http://localhost:8080", "description": "NetGet RSS feed",
149                "items": []}]
150else:
151    actions = []
152print(json.dumps({"actions": actions}))"#;
153
154        StartupExamples::new(
155            // LLM mode: LLM handles all RSS feed requests intelligently
156            json!({
157                "type": "open_server",
158                "port": 8080,
159                "base_stack": "rss",
160                "instruction": "RSS feed server generating dynamic feeds"
161            }),
162            // Script mode: Code-based deterministic responses
163            json!({
164                "type": "open_server",
165                "port": 8080,
166                "base_stack": "rss",
167                "event_handlers": [{
168                    "event_pattern": "rss_feed_requested",
169                    "handler": {
170                        "type": "script",
171                        "language": "python",
172                        "code": script
173                    }
174                }]
175            }),
176            // Static mode: Fixed responses
177            json!({
178                "type": "open_server",
179                "port": 8080,
180                "base_stack": "rss",
181                "event_handlers": [{
182                    "event_pattern": "rss_feed_requested",
183                    "handler": {
184                        "type": "static",
185                        "actions": [{
186                            "type": "generate_rss_feed",
187                            "title": "Default Feed",
188                            "link": "http://localhost:8080",
189                            "description": "Default RSS feed",
190                            "items": []
191                        }]
192                    }
193                }]
194            }),
195        )
196    }
197}
198
199// Implement Server trait (server-specific functionality)
200impl Server for RssProtocol {
201    fn spawn(
202        &self,
203        ctx: SpawnContext,
204    ) -> Pin<Box<dyn std::future::Future<Output = Result<std::net::SocketAddr>> + Send>> {
205        Box::pin(async move {
206            use crate::server::rss::RssServer;
207
208            RssServer::spawn_with_llm_actions(
209                ctx.legacy_listen_addr(),
210                ctx.llm_client,
211                ctx.state,
212                ctx.status_tx,
213                ctx.server_id,
214            )
215            .await
216        })
217    }
218
219    fn execute_action(&self, action: serde_json::Value) -> Result<ActionResult> {
220        let action_type = action
221            .get("type")
222            .and_then(|v| v.as_str())
223            .context("Missing 'type' field in action")?;
224
225        match action_type {
226            "generate_rss_feed" => self.execute_generate_feed(action),
227            _ => Err(anyhow::anyhow!("Unknown RSS action: {action_type}")),
228        }
229    }
230}
231
232impl RssProtocol {
233    /// Execute generate_rss_feed sync action
234    fn execute_generate_feed(&self, action: serde_json::Value) -> Result<ActionResult> {
235        // Validate here rather than in the server: a failure at this point lands in
236        // `ExecutionResult::failures`, which is what the model is shown and what the repair
237        // loop reads. `build_rss_from_llm_data` calls the same function as a last gate.
238        validate_feed_data(&action)?;
239
240        // Return custom result with feed data
241        Ok(ActionResult::Custom {
242            name: "generate_rss_feed".to_string(),
243            data: action,
244        })
245    }
246}
247
248/// Check the fields `generate_rss_feed` declares `required: true`.
249///
250/// The server used to substitute `"Untitled Feed"`, `"http://localhost"` and
251/// `"No description"` for a missing title, link and description, so an answer naming none of
252/// the three produced a complete-looking feed and nothing recorded that the model had not
253/// actually supplied one. A required field whose default asserts a result is the shape
254/// CLAUDE.md calls the most dangerous pattern here; refuse and say which field, so the
255/// message the repair loop reads names the fix.
256///
257/// `items` is required but may be empty: a feed with no entries is a legitimate answer
258/// ("nothing new"), whereas a feed with no title is not a feed.
259pub fn validate_feed_data(action: &serde_json::Value) -> Result<()> {
260    for field in ["title", "link", "description"] {
261        match action.get(field) {
262            None | Some(serde_json::Value::Null) => {
263                anyhow::bail!(
264                    "generate_rss_feed requires '{field}'. RSS 2.0 makes title, link and \
265                     description mandatory on the channel, and netget will not invent them."
266                );
267            }
268            Some(serde_json::Value::String(s)) if s.trim().is_empty() => {
269                anyhow::bail!("generate_rss_feed '{field}' must not be empty");
270            }
271            Some(serde_json::Value::String(_)) => {}
272            Some(other) => {
273                anyhow::bail!(
274                    "generate_rss_feed '{field}' must be a string, got {}",
275                    match other {
276                        serde_json::Value::Bool(_) => "a boolean",
277                        serde_json::Value::Number(_) => "a number",
278                        serde_json::Value::Array(_) => "an array",
279                        serde_json::Value::Object(_) => "an object",
280                        _ => "something else",
281                    }
282                );
283            }
284        }
285    }
286
287    match action.get("items") {
288        None | Some(serde_json::Value::Null) => anyhow::bail!(
289            "generate_rss_feed requires 'items'. Use an empty array for a feed with no entries."
290        ),
291        Some(serde_json::Value::Array(_)) => {}
292        Some(_) => anyhow::bail!("generate_rss_feed 'items' must be an array"),
293    }
294
295    Ok(())
296}
297
298/// Action definition for generate_rss_feed (sync)
299fn generate_rss_feed_action() -> ActionDefinition {
300    ActionDefinition {
301        name: "generate_rss_feed".to_string(),
302        description: "Generate RSS feed XML for the current request".to_string(),
303        parameters: vec![
304            Parameter {
305                name: "title".to_string(),
306                type_hint: "string".to_string(),
307                description: "Feed title".to_string(),
308                required: true,
309            },
310            Parameter {
311                name: "link".to_string(),
312                type_hint: "string".to_string(),
313                description: "Feed link (website URL)".to_string(),
314                required: true,
315            },
316            Parameter {
317                name: "description".to_string(),
318                type_hint: "string".to_string(),
319                description: "Feed description".to_string(),
320                required: true,
321            },
322            Parameter {
323                name: "language".to_string(),
324                type_hint: "string".to_string(),
325                description: "Feed language (e.g., 'en-us')".to_string(),
326                required: false,
327            },
328            Parameter {
329                name: "ttl".to_string(),
330                type_hint: "string".to_string(),
331                description: "Time to live in minutes".to_string(),
332                required: false,
333            },
334            Parameter {
335                name: "last_build_date".to_string(),
336                type_hint: "string".to_string(),
337                description: "Last build date (RFC 2822)".to_string(),
338                required: false,
339            },
340            Parameter {
341                name: "items".to_string(),
342                type_hint: "array".to_string(),
343                description: "Array of feed items".to_string(),
344                required: true,
345            },
346        ],
347        example: json!({
348            "type": "generate_rss_feed",
349            "title": "Tech News Feed",
350            "link": "https://example.com",
351            "description": "Latest technology news",
352            "language": "en-us",
353            "ttl": "60",
354            "last_build_date": "Mon, 09 Nov 2025 12:00:00 GMT",
355            "items": [
356                {
357                    "title": "New AI Model Released",
358                    "link": "https://example.com/ai-news",
359                    "description": "Company X released a new AI model",
360                    "author": "john@example.com (John Doe)",
361                    "pub_date": "Mon, 09 Nov 2025 10:00:00 GMT",
362                    "guid": "https://example.com/ai-news",
363                    "categories": [
364                        "AI",
365                        "Technology",
366                        {"name": "Machine Learning", "domain": "tech.example.com"}
367                    ]
368                },
369                {
370                    "title": "Cloud Computing Trends",
371                    "link": "https://example.com/cloud-trends",
372                    "description": "Latest trends in cloud computing",
373                    "pub_date": "Mon, 09 Nov 2025 09:00:00 GMT",
374                    "categories": ["Cloud", "Infrastructure"]
375                }
376            ]
377        }),
378        log_template: Some(
379            LogTemplate::new()
380                .with_info("-> RSS feed: {title}")
381                .with_debug("RSS generate_rss_feed: title={title} items={items_len}"),
382        ),
383    }
384}