Skip to main content

serve/
app.rs

1//! `App::builder().discover().build()`: collect what the macros registered,
2//! check it against the layout conventions, and freeze it.
3//!
4//! Decision: discovery never fails early. Every problem in the app (duplicate
5//! names, bad cron, unknown tool filters) is collected and reported together
6//! by `serve::start`, the way a compiler reports all errors in a crate.
7
8use std::collections::{BTreeMap, BTreeSet};
9use std::str::FromStr;
10use std::sync::Arc;
11
12use crate::agent::Agent;
13use crate::channel::Channel;
14use crate::config::AppConfig;
15use crate::registry::{
16    AgentRegistration, AppInfoRegistration, AssetRegistration, ChannelRegistration,
17    ConnectionRegistration, ConnectionValue, EvalRegistration, ScheduleRegistration,
18    ToolRegistration,
19};
20
21/// How the binary is running. Decides hot reload and model fallback.
22#[derive(Clone, Copy, Debug, PartialEq, Eq)]
23pub enum Mode {
24    /// `cargo run -- dev`: SQLite under `.serve/`, simulator fallback,
25    /// Markdown hot reload, a live console.
26    Dev,
27    /// `start`: production. Every model must route; missing secrets fail.
28    Start,
29    /// `eval`: in-process, simulator fallback, throwaway storage.
30    Eval,
31}
32
33impl Mode {
34    pub(crate) fn hot_reload(self) -> bool {
35        self == Mode::Dev
36    }
37
38    pub(crate) fn allow_offline(self) -> bool {
39        self != Mode::Start
40    }
41}
42
43/// A discovered agent.
44#[derive(Clone)]
45pub(crate) struct AgentEntry {
46    pub name: String,
47    pub doc: String,
48    pub sub: bool,
49    pub default: bool,
50    pub source: String,
51    pub spec: Agent,
52}
53
54/// A discovered channel.
55#[derive(Clone)]
56pub(crate) struct ChannelEntry {
57    pub name: &'static str,
58    pub source: &'static str,
59    pub channel: Arc<dyn Channel>,
60}
61
62/// A discovered schedule with its parsed cron.
63#[derive(Clone)]
64pub(crate) struct ScheduleEntry {
65    pub registration: &'static ScheduleRegistration,
66    pub schedule: cron::Schedule,
67}
68
69/// A discovered connection.
70#[derive(Clone)]
71pub(crate) struct ConnectionEntry {
72    pub source: &'static str,
73    pub value: ConnectionValue,
74}
75
76/// A skill from `agent/skills/<name>/SKILL.md`.
77#[derive(Clone, Debug)]
78pub(crate) struct Skill {
79    pub name: String,
80    pub description: String,
81    pub asset: &'static AssetRegistration,
82}
83
84pub(crate) struct AppInner {
85    pub name: String,
86    pub version: String,
87    pub config: AppConfig,
88    pub agents: Vec<AgentEntry>,
89    pub tools: Vec<&'static ToolRegistration>,
90    pub channels: Vec<ChannelEntry>,
91    pub schedules: Vec<ScheduleEntry>,
92    pub connections: Vec<ConnectionEntry>,
93    pub evals: Vec<&'static EvalRegistration>,
94    pub assets: Vec<&'static AssetRegistration>,
95    pub skills: Vec<Skill>,
96    pub warnings: Vec<String>,
97    pub errors: Vec<String>,
98}
99
100/// A discovered, validated app. Cheap to clone.
101#[derive(Clone)]
102pub struct App {
103    pub(crate) inner: Arc<AppInner>,
104}
105
106impl App {
107    pub fn builder() -> AppBuilder {
108        AppBuilder::default()
109    }
110
111    pub fn name(&self) -> &str {
112        &self.inner.name
113    }
114
115    /// Problems that stop the app from starting.
116    pub fn errors(&self) -> &[String] {
117        &self.inner.errors
118    }
119
120    /// Convention drift worth fixing but not fatal.
121    pub fn warnings(&self) -> &[String] {
122        &self.inner.warnings
123    }
124
125    /// The build-time contract with the host.
126    pub fn manifest(&self) -> crate::Manifest {
127        crate::Manifest::of(self)
128    }
129
130    pub(crate) fn agent(&self, name: &str) -> Option<&AgentEntry> {
131        self.inner.agents.iter().find(|agent| agent.name == name)
132    }
133
134    /// The agent `POST /v1/sessions` uses when the body names none.
135    pub(crate) fn default_agent(&self) -> Option<&AgentEntry> {
136        let top: Vec<_> = self.inner.agents.iter().filter(|a| !a.sub).collect();
137        top.iter()
138            .find(|agent| agent.default)
139            .or_else(|| (top.len() == 1).then(|| &top[0]))
140            .copied()
141    }
142
143    pub(crate) fn channel(&self, name: &str) -> Option<&ChannelEntry> {
144        self.inner.channels.iter().find(|entry| entry.name == name)
145    }
146
147    pub(crate) fn asset(&self, path: &str) -> Option<&'static AssetRegistration> {
148        self.inner
149            .assets
150            .iter()
151            .copied()
152            .find(|asset| asset.path == path)
153    }
154
155    /// Tools an agent gets: its filter, or every discovered tool.
156    pub(crate) fn tools_for(&self, agent: &AgentEntry) -> Vec<&'static ToolRegistration> {
157        match &agent.spec.tools {
158            Some(names) => self
159                .inner
160                .tools
161                .iter()
162                .copied()
163                .filter(|tool| names.iter().any(|name| name == tool.name))
164                .collect(),
165            None => self.inner.tools.clone(),
166        }
167    }
168}
169
170/// Builder for [`App`].
171#[derive(Default)]
172#[must_use]
173pub struct AppBuilder {
174    discover: bool,
175    config: Option<AppConfig>,
176    packages: Vec<(String, crate::Result<Agent>)>,
177}
178
179impl AppBuilder {
180    /// Load a file-based agent before freezing the app. Assets are read once;
181    /// restart the app to apply edits. Errors join discovery diagnostics.
182    pub fn agent_package(mut self, path: impl AsRef<std::path::Path>) -> Self {
183        let path = path.as_ref();
184        self.packages
185            .push((path.display().to_string(), Agent::from_package(path)));
186        self
187    }
188
189    /// Add an already materialized file agent, for example an embedded ZIP.
190    pub fn package(mut self, package: everruns::AgentPackage) -> Self {
191        self.packages
192            .push(("agent-package".into(), Agent::package(package)));
193        self
194    }
195
196    /// Collect everything the attribute macros registered in this binary.
197    pub fn discover(mut self) -> Self {
198        self.discover = true;
199        self
200    }
201
202    /// Use this configuration instead of the embedded `serve.toml`.
203    pub fn config(mut self, config: AppConfig) -> Self {
204        self.config = Some(config);
205        self
206    }
207
208    /// Freeze the app. Problems are recorded, not returned: `serve::start`
209    /// prints them all and exits. Use [`try_build`](Self::try_build) to get
210    /// them as an error instead.
211    pub fn build(self) -> App {
212        let mut errors = Vec::new();
213        let mut warnings = Vec::new();
214        if !self.discover && self.packages.is_empty() {
215            warnings.push("App built without .discover(); it has no agents".to_string());
216        }
217        let assets: Vec<&'static AssetRegistration> = if self.discover {
218            inventory::iter::<AssetRegistration>.into_iter().collect()
219        } else {
220            Vec::new()
221        };
222        let config = match self.config {
223            Some(config) => config,
224            None => match assets.iter().find(|asset| asset.path == "@serve.toml") {
225                Some(asset) => AppConfig::parse(asset.contents).unwrap_or_else(|err| {
226                    errors.push(format!("serve.toml: {err}"));
227                    AppConfig::default()
228                }),
229                None => AppConfig::default(),
230            },
231        };
232        let info = inventory::iter::<AppInfoRegistration>.into_iter().next();
233        let name = config
234            .name
235            .clone()
236            .or_else(|| info.map(|info| info.name.to_string()))
237            .unwrap_or_else(|| "serve-app".to_string());
238        let version = info
239            .map(|info| info.version.to_string())
240            .unwrap_or_else(|| "0.0.0".to_string());
241        if self.discover && info.is_none() {
242            warnings.push(
243                "serve::assets!() was not called; agent/** and serve.toml are not embedded"
244                    .to_string(),
245            );
246        }
247
248        let mut app = AppInner {
249            name,
250            version,
251            config,
252            agents: Vec::new(),
253            tools: Vec::new(),
254            channels: Vec::new(),
255            schedules: Vec::new(),
256            connections: Vec::new(),
257            evals: Vec::new(),
258            skills: skills(&assets, &mut warnings),
259            assets,
260            warnings,
261            errors,
262        };
263        if self.discover {
264            discover(&mut app);
265        }
266        for (source, spec) in self.packages {
267            match spec {
268                Ok(spec) => {
269                    let Some(package) = spec.package.as_ref() else {
270                        app.errors
271                            .push(format!("{source}: missing package definition"));
272                        continue;
273                    };
274                    let name = package.manifest().name.clone();
275                    if app.agents.iter().any(|a| a.name == name) {
276                        app.errors.push(format!("duplicate agent {name}"));
277                        continue;
278                    }
279                    for tool in spec.tools.iter().flatten() {
280                        if !app.tools.iter().any(|t| t.name == tool) {
281                            app.errors
282                                .push(format!("agent {name} requires host tool {tool}"));
283                        }
284                    }
285                    app.agents.push(AgentEntry {
286                        name,
287                        doc: String::new(),
288                        source,
289                        sub: false,
290                        default: false,
291                        spec,
292                    });
293                }
294                Err(error) => app.errors.push(format!("{source}: {error}")),
295            }
296        }
297        if !app.agents.iter().any(|a| !a.sub) {
298            app.errors
299                .push("no agents found; register an agent or load .agent_package(path)".into());
300        }
301        App {
302            inner: Arc::new(app),
303        }
304    }
305
306    /// Like [`build`](Self::build), but fail with every problem found.
307    pub fn try_build(self) -> crate::Result<App> {
308        let app = self.build();
309        if app.errors().is_empty() {
310            Ok(app)
311        } else {
312            anyhow::bail!("{}", app.errors().join("\n"))
313        }
314    }
315}
316
317fn discover(app: &mut AppInner) {
318    let mut tools: Vec<&'static ToolRegistration> =
319        inventory::iter::<ToolRegistration>.into_iter().collect();
320    tools.sort_by_key(|tool| tool.name);
321    check_unique(&mut app.errors, "tool", tools.iter().map(|t| t.name));
322    for tool in &tools {
323        convention(&mut app.warnings, "tool", tool.name, tool.source, "tools");
324    }
325
326    let mut agents: Vec<&'static AgentRegistration> =
327        inventory::iter::<AgentRegistration>.into_iter().collect();
328    agents.sort_by_key(|agent| agent.name);
329    check_unique(&mut app.errors, "agent", agents.iter().map(|a| a.name));
330    for registration in agents {
331        let spec = (registration.build)();
332        let dir = if registration.sub { "subagents" } else { "" };
333        if !dir.is_empty() {
334            convention(
335                &mut app.warnings,
336                "subagent",
337                registration.name,
338                registration.source,
339                dir,
340            );
341        }
342        if spec.model.trim().is_empty() {
343            app.errors.push(format!(
344                "agent `{}` ({}) has no model; call .model(\"provider/model\")",
345                registration.name, registration.source
346            ));
347        }
348        if let Some(names) = &spec.tools {
349            for name in names {
350                if !tools.iter().any(|tool| tool.name == name) {
351                    app.errors.push(format!(
352                        "agent `{}` lists unknown tool `{name}`",
353                        registration.name
354                    ));
355                }
356            }
357        }
358        if registration.sub
359            && tools
360                .iter()
361                .any(|tool| tool.name == format!("ask_{}", registration.name))
362        {
363            app.errors.push(format!(
364                "tool `ask_{0}` collides with the tool generated for subagent `{0}`",
365                registration.name
366            ));
367        }
368        app.agents.push(AgentEntry {
369            name: registration.name.into(),
370            doc: registration.doc.into(),
371            sub: registration.sub,
372            default: registration.default,
373            source: registration.source.into(),
374            spec,
375        });
376    }
377    let top: Vec<_> = app.agents.iter().filter(|a| !a.sub).collect();
378    let defaults = top.iter().filter(|a| a.default).count();
379    if defaults > 1 {
380        app.errors
381            .push("more than one #[agent(default)]; mark exactly one".into());
382    } else if top.len() > 1 && defaults == 0 {
383        app.warnings.push(
384            "several agents and none is #[agent(default)]; sessions must name one with agent_name"
385                .into(),
386        );
387    }
388    app.tools = tools;
389
390    let mut channels: Vec<&'static ChannelRegistration> =
391        inventory::iter::<ChannelRegistration>.into_iter().collect();
392    channels.sort_by_key(|channel| channel.name);
393    check_unique(&mut app.errors, "channel", channels.iter().map(|c| c.name));
394    for registration in channels {
395        convention(
396            &mut app.warnings,
397            "channel",
398            registration.name,
399            registration.source,
400            "channels",
401        );
402        app.channels.push(ChannelEntry {
403            name: registration.name,
404            source: registration.source,
405            channel: Arc::from((registration.build)()),
406        });
407    }
408
409    let mut schedules: Vec<&'static ScheduleRegistration> = inventory::iter::<ScheduleRegistration>
410        .into_iter()
411        .collect();
412    schedules.sort_by_key(|schedule| schedule.name);
413    check_unique(
414        &mut app.errors,
415        "schedule",
416        schedules.iter().map(|s| s.name),
417    );
418    for registration in schedules {
419        convention(
420            &mut app.warnings,
421            "schedule",
422            registration.name,
423            registration.source,
424            "schedules",
425        );
426        match parse_cron(registration.cron) {
427            Ok(schedule) => app.schedules.push(ScheduleEntry {
428                registration,
429                schedule,
430            }),
431            Err(err) => app.errors.push(format!(
432                "schedule `{}` has an invalid cron `{}`: {err}",
433                registration.name, registration.cron
434            )),
435        }
436    }
437
438    let mut connections: Vec<&'static ConnectionRegistration> =
439        inventory::iter::<ConnectionRegistration>
440            .into_iter()
441            .collect();
442    connections.sort_by_key(|connection| connection.name);
443    check_unique(
444        &mut app.errors,
445        "connection",
446        connections.iter().map(|c| c.name),
447    );
448    let mut types = BTreeMap::new();
449    for registration in connections {
450        convention(
451            &mut app.warnings,
452            "connection",
453            registration.name,
454            registration.source,
455            "connections",
456        );
457        let value = match (registration.build)() {
458            Ok(value) => value,
459            Err(err) => {
460                app.errors.push(format!(
461                    "connection `{}` ({}) failed to build: {err:#}",
462                    registration.name, registration.source
463                ));
464                continue;
465            }
466        };
467        if value.mcp.is_none()
468            && let Some(other) = types.insert(value.type_name, registration.name)
469        {
470            app.errors.push(format!(
471                "connections `{other}` and `{}` have the same type `{}`; cx.connection::<T>() could not tell them apart",
472                registration.name, value.type_name
473            ));
474        }
475        app.connections.push(ConnectionEntry {
476            source: registration.source,
477            value,
478        });
479    }
480
481    let mut evals: Vec<&'static EvalRegistration> =
482        inventory::iter::<EvalRegistration>.into_iter().collect();
483    evals.sort_by_key(|eval| eval.name);
484    check_unique(&mut app.errors, "eval", evals.iter().map(|e| e.name));
485    app.evals = evals;
486}
487
488/// Five-field cron gains a seconds field; six or seven fields pass through.
489pub(crate) fn parse_cron(expr: &str) -> Result<cron::Schedule, cron::error::Error> {
490    let fields = expr.split_whitespace().count();
491    let expr = if fields == 5 {
492        format!("0 {expr}")
493    } else {
494        expr.to_string()
495    };
496    cron::Schedule::from_str(&expr)
497}
498
499fn check_unique<'a>(errors: &mut Vec<String>, kind: &str, names: impl Iterator<Item = &'a str>) {
500    let mut seen = BTreeSet::new();
501    for name in names {
502        if !seen.insert(name) {
503            errors.push(format!("two {kind}s are named `{name}`"));
504        }
505    }
506}
507
508/// Warn when an item lives outside its conventional directory. The layout is
509/// what makes an app readable at a glance, as in eve; it is not enforced.
510fn convention(warnings: &mut Vec<String>, kind: &str, name: &str, source: &str, dir: &str) {
511    let source = source.replace('\\', "/");
512    if !source.contains(&format!("/{dir}/")) && !source.contains(&format!("/{dir}.rs")) {
513        warnings.push(format!(
514            "{kind} `{name}` is defined in {source}; by convention it lives in src/{dir}/{name}.rs"
515        ));
516    }
517}
518
519fn skills(assets: &[&'static AssetRegistration], warnings: &mut Vec<String>) -> Vec<Skill> {
520    let mut skills = Vec::new();
521    for asset in assets {
522        let Some(rest) = asset.path.strip_prefix("skills/") else {
523            continue;
524        };
525        let Some(dir) = rest.strip_suffix("/SKILL.md") else {
526            continue;
527        };
528        let (name, description) = frontmatter(asset.contents);
529        let name = name.unwrap_or_else(|| dir.to_string());
530        let description = description.unwrap_or_else(|| {
531            warnings.push(format!(
532                "skill `{name}` has no `description:` in its frontmatter; the model sees only its name"
533            ));
534            String::new()
535        });
536        skills.push(Skill {
537            name,
538            description,
539            asset,
540        });
541    }
542    skills.sort_by(|a, b| a.name.cmp(&b.name));
543    skills
544}
545
546/// Read `name:` and `description:` from a `---` frontmatter block.
547pub(crate) fn frontmatter(text: &str) -> (Option<String>, Option<String>) {
548    let mut lines = text.lines();
549    if lines.next().map(str::trim) != Some("---") {
550        return (None, None);
551    }
552    let mut name = None;
553    let mut description = None;
554    for line in lines {
555        let line = line.trim();
556        if line == "---" {
557            break;
558        }
559        if let Some(value) = line.strip_prefix("name:") {
560            name = Some(value.trim().trim_matches('"').to_string());
561        } else if let Some(value) = line.strip_prefix("description:") {
562            description = Some(value.trim().trim_matches('"').to_string());
563        }
564    }
565    (name, description)
566}
567
568#[cfg(test)]
569mod tests {
570    use super::*;
571
572    #[test]
573    fn five_field_cron_gets_seconds() {
574        let schedule = parse_cron("0 9 * * MON").unwrap();
575        assert!(schedule.upcoming(chrono::Utc).next().is_some());
576        assert!(parse_cron("0 0 9 * * MON").is_ok());
577        assert!(parse_cron("every monday").is_err());
578    }
579
580    #[test]
581    fn frontmatter_reads_name_and_description() {
582        let text = "---\nname: sql-style\ndescription: \"How we write SQL\"\n---\n# Body\n";
583        assert_eq!(
584            frontmatter(text),
585            (Some("sql-style".into()), Some("How we write SQL".into()))
586        );
587        assert_eq!(frontmatter("# no frontmatter"), (None, None));
588    }
589
590    #[test]
591    fn convention_warns_outside_the_directory() {
592        let mut warnings = Vec::new();
593        convention(
594            &mut warnings,
595            "tool",
596            "run_sql",
597            "src/tools/run_sql.rs",
598            "tools",
599        );
600        convention(&mut warnings, "tool", "run_sql", "src/tools.rs", "tools");
601        assert!(warnings.is_empty(), "{warnings:?}");
602        convention(&mut warnings, "tool", "run_sql", "src/main.rs", "tools");
603        assert_eq!(warnings.len(), 1);
604        assert!(warnings[0].contains("src/tools/run_sql.rs"));
605    }
606
607    #[test]
608    fn duplicates_are_reported() {
609        let mut errors = Vec::new();
610        check_unique(&mut errors, "tool", ["a", "b", "a"].into_iter());
611        assert_eq!(errors, vec!["two tools are named `a`"]);
612    }
613}