Skip to main content

serve/
agent.rs

1//! The declarative agent spec returned by `#[agent]` functions.
2//!
3//! Decision: `serve::Agent` is a description (model string, instructions,
4//! tool filter), not an `everruns::Agent`. The host turns it into an
5//! `everruns::Agent` per session, after it has resolved the model through the
6//! gateway, attached discovered tools/skills/connections, and bound a `Cx`
7//! for that session. Keeping the spec declarative is what lets the manifest
8//! list model strings and lets `dev` hot-reload the instructions.
9
10use std::sync::Arc;
11
12use everruns::LlmSimConfig;
13
14/// A Markdown file from `agent/`, embedded at build time. Create with
15/// [`md!`](crate::md).
16#[derive(Clone, Debug)]
17pub struct Markdown {
18    path: &'static str,
19    embedded: &'static str,
20    disk: &'static str,
21}
22
23impl Markdown {
24    #[doc(hidden)]
25    pub const fn __embedded(
26        path: &'static str,
27        embedded: &'static str,
28        disk: &'static str,
29    ) -> Self {
30        Self {
31            path,
32            embedded,
33            disk,
34        }
35    }
36
37    /// Path relative to `agent/`.
38    pub fn path(&self) -> &'static str {
39        self.path
40    }
41
42    /// The text: from disk in `dev` (hot reload), else as embedded.
43    pub fn load(&self, hot_reload: bool) -> String {
44        if hot_reload && let Ok(text) = std::fs::read_to_string(self.disk) {
45            return text;
46        }
47        self.embedded.to_string()
48    }
49}
50
51/// Where an agent's always-on prompt comes from.
52#[derive(Clone, Debug)]
53pub enum Instructions {
54    Text(String),
55    Markdown(Markdown),
56}
57
58impl Instructions {
59    pub(crate) fn load(&self, hot_reload: bool) -> String {
60        match self {
61            Instructions::Text(text) => text.clone(),
62            Instructions::Markdown(markdown) => markdown.load(hot_reload),
63        }
64    }
65}
66
67impl From<&str> for Instructions {
68    fn from(text: &str) -> Self {
69        Instructions::Text(text.to_string())
70    }
71}
72
73impl From<String> for Instructions {
74    fn from(text: String) -> Self {
75        Instructions::Text(text)
76    }
77}
78
79impl From<Markdown> for Instructions {
80    fn from(markdown: Markdown) -> Self {
81        Instructions::Markdown(markdown)
82    }
83}
84
85type Customize = Arc<dyn Fn(everruns::AgentBuilder) -> everruns::AgentBuilder + Send + Sync>;
86
87/// An agent of this app. Build one with [`Agent::builder`] inside an
88/// `#[agent]` function.
89#[derive(Clone)]
90pub struct Agent {
91    pub(crate) model: String,
92    pub(crate) instructions: Option<Instructions>,
93    pub(crate) description: Option<String>,
94    pub(crate) tools: Option<Vec<String>>,
95    pub(crate) offline: Option<LlmSimConfig>,
96    pub(crate) customize: Option<Customize>,
97    pub(crate) package: Option<everruns::AgentPackage>,
98}
99
100impl std::fmt::Debug for Agent {
101    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
102        f.debug_struct("Agent")
103            .field("model", &self.model)
104            .field("instructions", &self.instructions)
105            .field("tools", &self.tools)
106            .finish_non_exhaustive()
107    }
108}
109
110impl Agent {
111    /// Load a portable agent file, folder or ZIP. A missing model uses the
112    /// serve simulator; production apps should declare a gateway model.
113    pub fn from_package(path: impl AsRef<std::path::Path>) -> crate::Result<Self> {
114        Self::package(everruns::AgentPackage::load(path)?)
115    }
116
117    /// Serve a materialized package, including archives embedded in a binary.
118    pub fn package(package: everruns::AgentPackage) -> crate::Result<Self> {
119        let m = package.manifest();
120        if m.environments.is_some() {
121            anyhow::bail!("package environments require an explicit host binding");
122        }
123        if let Some(harness) = m.harness.as_deref()
124            && !matches!(harness, "base" | "conversation" | "worker-base" | "worker")
125        {
126            anyhow::bail!(
127                "package harness {harness} requires an explicit Framework session binding"
128            );
129        }
130        let model = m
131            .model
132            .as_ref()
133            .map(|m| {
134                if m.provider.as_str() == "llmsim" {
135                    "sim".into()
136                } else {
137                    format!("{}/{}", m.provider, m.model)
138                }
139            })
140            .unwrap_or_else(|| "sim".into());
141        let mut spec = Self::builder()
142            .model(model)
143            .instructions(m.instructions.clone())
144            .build();
145        spec.description = m.description.clone();
146        // A file agent gets only handlers declared in its package.
147        spec.tools = Some(m.tools.iter().map(|t| t.name().into()).collect());
148        spec.package = Some(package);
149        Ok(spec)
150    }
151
152    /// Start describing an agent.
153    pub fn builder() -> AgentBuilder {
154        AgentBuilder {
155            agent: Agent {
156                model: String::new(),
157                instructions: None,
158                description: None,
159                tools: None,
160                offline: None,
161                customize: None,
162                package: None,
163            },
164        }
165    }
166
167    /// The model string, e.g. `"anthropic/claude-sonnet-5"`.
168    pub fn model(&self) -> &str {
169        &self.model
170    }
171}
172
173/// Builder for [`Agent`].
174#[must_use]
175pub struct AgentBuilder {
176    agent: Agent,
177}
178
179impl AgentBuilder {
180    /// A gateway model string, `provider/model`. The host resolves it; the
181    /// app carries no provider keys. `"sim"` always uses the simulator.
182    pub fn model(mut self, model: impl Into<String>) -> Self {
183        self.agent.model = model.into();
184        self
185    }
186
187    /// The always-on prompt. Defaults to `agent/instructions.md` when that
188    /// file exists.
189    pub fn instructions(mut self, instructions: impl Into<Instructions>) -> Self {
190        self.agent.instructions = Some(instructions.into());
191        self
192    }
193
194    /// One line on what this agent does. Shown in the agent card; for a
195    /// subagent it is the description of its `ask_<name>` tool.
196    pub fn description(mut self, description: impl Into<String>) -> Self {
197        self.agent.description = Some(description.into());
198        self
199    }
200
201    /// Restrict the agent to these discovered tools. By default an agent gets
202    /// every `#[tool]` in the app.
203    pub fn tools<I, S>(mut self, names: I) -> Self
204    where
205        I: IntoIterator<Item = S>,
206        S: Into<String>,
207    {
208        self.agent.tools = Some(names.into_iter().map(Into::into).collect());
209        self
210    }
211
212    /// The simulator script used when no model gateway is configured, so the
213    /// app runs offline in `dev` and in evals. See [`crate::sim`].
214    pub fn offline(mut self, script: LlmSimConfig) -> Self {
215        self.agent.offline = Some(script);
216        self
217    }
218
219    /// Escape hatch: adjust the underlying `everruns::AgentBuilder` (hooks,
220    /// capabilities, iteration limits) after serve has configured it.
221    pub fn customize(
222        mut self,
223        f: impl Fn(everruns::AgentBuilder) -> everruns::AgentBuilder + Send + Sync + 'static,
224    ) -> Self {
225        self.agent.customize = Some(Arc::new(f));
226        self
227    }
228
229    /// Finish. Validation happens in `App::builder().discover()`, where every
230    /// problem in the app is reported together.
231    pub fn build(self) -> Agent {
232        self.agent
233    }
234}