1use 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#[derive(Clone, Copy, Debug, PartialEq, Eq)]
23pub enum Mode {
24 Dev,
27 Start,
29 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#[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#[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#[derive(Clone)]
64pub(crate) struct ScheduleEntry {
65 pub registration: &'static ScheduleRegistration,
66 pub schedule: cron::Schedule,
67}
68
69#[derive(Clone)]
71pub(crate) struct ConnectionEntry {
72 pub source: &'static str,
73 pub value: ConnectionValue,
74}
75
76#[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#[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 pub fn errors(&self) -> &[String] {
117 &self.inner.errors
118 }
119
120 pub fn warnings(&self) -> &[String] {
122 &self.inner.warnings
123 }
124
125 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 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 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#[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 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 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 pub fn discover(mut self) -> Self {
198 self.discover = true;
199 self
200 }
201
202 pub fn config(mut self, config: AppConfig) -> Self {
204 self.config = Some(config);
205 self
206 }
207
208 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 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
488pub(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
508fn 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
546pub(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}