Skip to main content

newsletter/
newsletter.rs

1use agent_line::{Agent, Ctx, Outcome, Runner, StepResult, Workflow};
2
3// ---------------------------------------------------------------------------
4// State types
5// ---------------------------------------------------------------------------
6
7#[derive(Clone, Debug)]
8struct TopicState {
9    query: String,
10    topics: Vec<String>,
11    selected: Vec<String>,
12}
13
14#[derive(Clone, Debug)]
15struct ArticleState {
16    topic: String,
17    draft: String,
18    revision: u32,
19}
20
21// ---------------------------------------------------------------------------
22// Phase 1 agents: find and pick topics
23// ---------------------------------------------------------------------------
24
25struct TopicSearcher;
26impl Agent<TopicState> for TopicSearcher {
27    fn name(&self) -> &'static str {
28        "topic_searcher"
29    }
30    fn run(&mut self, mut state: TopicState, ctx: &mut Ctx) -> StepResult<TopicState> {
31        ctx.log(format!("searching for: {}", state.query));
32
33        // Stub: pretend we did a web search
34        state.topics = vec![
35            "Rust in embedded systems".into(),
36            "Why plumbers love side projects".into(),
37            "Welding meets software: CNC pipelines".into(),
38            "HVAC technicians automating schedules".into(),
39            "Electricians using Raspberry Pi on the job".into(),
40        ];
41
42        ctx.log(format!("found {} topics", state.topics.len()));
43        Ok((state, Outcome::Continue))
44    }
45}
46
47struct TopicPicker;
48impl Agent<TopicState> for TopicPicker {
49    fn name(&self) -> &'static str {
50        "topic_picker"
51    }
52    fn run(&mut self, mut state: TopicState, ctx: &mut Ctx) -> StepResult<TopicState> {
53        let response = ctx.llm()
54            .system("You are a newsletter curator. Pick exactly 3 topics. Return one per line, nothing else.")
55            .user(format!("Choose from:\n{}", state.topics.join("\n")))
56            .send()?;
57
58        // brittle, but should pick out the 3
59        state.selected = response
60            .lines()
61            .map(|l| l.trim())
62            .map(|l| {
63                l.trim_start_matches(|c: char| c.is_numeric() || c == '.' || c == '-' || c == ' ')
64            })
65            .map(|l| l.trim().to_string())
66            .filter(|l| !l.is_empty())
67            .collect();
68
69        for topic in &state.selected {
70            ctx.log(format!("selected: {topic}"));
71        }
72
73        Ok((state, Outcome::Done))
74    }
75}
76
77// ---------------------------------------------------------------------------
78// Phase 2 agents: write, validate, fix one article
79// ---------------------------------------------------------------------------
80
81struct ArticleWriter;
82impl Agent<ArticleState> for ArticleWriter {
83    fn name(&self) -> &'static str {
84        "article_writer"
85    }
86    fn run(&mut self, mut state: ArticleState, ctx: &mut Ctx) -> StepResult<ArticleState> {
87        state.revision += 1;
88        ctx.log(format!(
89            "writing draft {} for: {}",
90            state.revision, state.topic
91        ));
92
93        // Stub: first draft is sloppy, second is clean
94        if state.revision == 1 {
95            state.draft = format!(
96                "# {}\n\nThis is a artcle about {}. It has lots of good infomation.",
97                state.topic, state.topic
98            );
99        } else {
100            state.draft = format!(
101                "# {}\n\nThis is an article about {}. It has lots of good information.",
102                state.topic, state.topic
103            );
104        }
105
106        Ok((state, Outcome::Continue))
107    }
108}
109
110struct ArticleValidator;
111impl Agent<ArticleState> for ArticleValidator {
112    fn name(&self) -> &'static str {
113        "article_validator"
114    }
115    fn run(&mut self, state: ArticleState, ctx: &mut Ctx) -> StepResult<ArticleState> {
116        // use store k/v to pull in validation rules and pass them to the llm
117        let response = ctx
118            .llm()
119            .system("You are a strict editor. List any errors. Say PASS if none.")
120            .user(&state.draft)
121            .send()?;
122
123        // ctx,store get rules or previous work?
124        if response.contains("PASS") {
125            Ok((state, Outcome::Done))
126        } else {
127            ctx.set("errors", &response);
128            Ok((state, Outcome::Next("article_fixer")))
129        }
130    }
131}
132
133struct ArticleFixer;
134impl Agent<ArticleState> for ArticleFixer {
135    fn name(&self) -> &'static str {
136        "article_fixer"
137    }
138    fn run(&mut self, mut state: ArticleState, ctx: &mut Ctx) -> StepResult<ArticleState> {
139        let errors = ctx.get("errors").unwrap_or("no errors found").to_string();
140        let response = ctx
141            .llm()
142            .system("You are a writer. Rewrite the article fixing only the listed errors.")
143            .user(format!("Errors:\n{errors}\n\nArticle:\n{}", state.draft))
144            .send()?;
145
146        state.draft = response;
147        Ok((state, Outcome::Next("article_validator")))
148    }
149}
150
151// ---------------------------------------------------------------------------
152// Orchestrator
153// ---------------------------------------------------------------------------
154
155fn main() {
156    let mut ctx = Ctx::new();
157
158    // could populate ctx.store with some writing rules or could read in a markdown skill and pass
159    // that into the agent.
160    // Phase 1: find topics
161    let topic_wf = Workflow::builder("find-topics")
162        .register(TopicSearcher)
163        .register(TopicPicker)
164        .start_at("topic_searcher")
165        .then("topic_picker")
166        .build()
167        .unwrap();
168
169    let mut topic_runner = Runner::new(topic_wf);
170    let topics = topic_runner
171        .run(
172            TopicState {
173                query: "bluecollar engineering newsletter".into(),
174                topics: vec![],
175                selected: vec![],
176            },
177            &mut ctx,
178        )
179        .unwrap();
180
181    println!("=== Topics ===");
182    for entry in ctx.logs() {
183        println!("  {entry}");
184    }
185    ctx.clear_logs();
186    println!();
187
188    // Phase 2: write one article per topic
189    let article_wf = Workflow::builder("write-article")
190        .register(ArticleWriter)
191        .register(ArticleValidator)
192        .register(ArticleFixer)
193        .start_at("article_writer")
194        .then("article_validator")
195        .build()
196        .unwrap();
197
198    let mut article_runner = Runner::new(article_wf);
199    let mut finished_articles: Vec<String> = Vec::new();
200
201    for (i, topic) in topics.selected.iter().enumerate() {
202        println!("=== Article {} ===", i + 1);
203
204        let result = article_runner
205            .run(
206                ArticleState {
207                    topic: topic.clone(),
208                    draft: String::new(),
209                    revision: 0,
210                },
211                &mut ctx,
212            )
213            .unwrap();
214
215        finished_articles.push(result.draft.clone());
216        println!("  Revisions: {}", result.revision);
217
218        for entry in ctx.logs() {
219            println!("  {entry}");
220        }
221        ctx.clear_logs();
222        println!();
223    }
224
225    // Phase 3: "store" the articles
226    println!("=== Stored ===");
227    for (i, article) in finished_articles.iter().enumerate() {
228        let preview: String = article.chars().take(60).collect();
229        println!("  article_{}.md: {preview}...", i + 1);
230    }
231}