Skip to main content

apiplant_core/
agent.rs

1//! The declarative agent model.
2//!
3//! An *agent* is one `agents/<name>.toml` file. It names a configured AI chat
4//! surface — prompt, access policy and whether its history is persisted. When
5//! storage is enabled the app gets two generated resources, one for threads and
6//! one for messages, so migrations and read-only history browsing use the same
7//! machinery as every other table.
8
9use crate::config::AiConfig;
10use crate::schema::{titleize, Access, Resource, Scope};
11use serde::Deserialize;
12use serde_json::{json, Value};
13use std::collections::BTreeMap;
14use std::path::Path;
15
16/// One configured chat agent.
17#[derive(Debug, Clone, Deserialize)]
18pub struct Agent {
19    #[serde(rename = "agent")]
20    pub meta: AgentMeta,
21    /// Optional per-agent AI provider overrides. When absent, the app's
22    /// global `[ai]` configuration answers this agent too.
23    #[serde(default)]
24    pub ai: Option<AgentAiOverride>,
25    #[serde(default)]
26    pub tools: Vec<AgentTool>,
27    #[serde(default)]
28    pub permissions: AgentPermissions,
29}
30
31/// The `[agent]` section.
32#[derive(Debug, Clone, Deserialize)]
33pub struct AgentMeta {
34    pub name: String,
35    /// Human description, surfaced in docs and discovery responses.
36    #[serde(default)]
37    pub description: String,
38    /// System prompt this agent always answers under.
39    #[serde(default)]
40    pub system: String,
41    /// Legacy per-agent model override.
42    ///
43    /// Kept for compatibility with older agent files. New overrides belong in
44    /// the top-level `[ai]` table of the agent file, which can also override
45    /// the provider, endpoint, key and timeout.
46    #[serde(default)]
47    pub model: Option<String>,
48    /// Legacy per-agent temperature override.
49    #[serde(default)]
50    pub temperature: Option<f32>,
51    /// Legacy per-agent max_tokens override.
52    #[serde(default)]
53    pub max_tokens: Option<u32>,
54    /// Whether persisted threads/messages belong to an organisation or are
55    /// shared deployment-wide. Only meaningful when storage is enabled.
56    #[serde(default = "default_scope")]
57    pub scope: Scope,
58    #[serde(default)]
59    pub storage: AgentStorage,
60}
61
62fn default_scope() -> Scope {
63    Scope::Global
64}
65
66/// Optional persisted history for an agent.
67#[derive(Debug, Clone, Default, Deserialize)]
68#[serde(default)]
69pub struct AgentStorage {
70    /// Keep threads and messages in generated resources.
71    pub enabled: bool,
72}
73
74/// Optional per-agent AI client overrides, from a top-level `[ai]` table in
75/// the agent file.
76#[derive(Debug, Clone, Default, Deserialize)]
77#[serde(default)]
78pub struct AgentAiOverride {
79    pub provider: Option<String>,
80    pub endpoint: Option<String>,
81    pub model: Option<String>,
82    pub api_key: Option<String>,
83    pub system: Option<String>,
84    pub max_tokens: Option<u32>,
85    pub temperature: Option<f32>,
86    pub timeout_secs: Option<u64>,
87}
88
89/// One function-backed tool an agent may ask the model to call.
90#[derive(Debug, Clone, Deserialize)]
91pub struct AgentTool {
92    /// Name exposed to the model.
93    pub name: String,
94    /// Human description passed to the model.
95    #[serde(default)]
96    pub description: String,
97    /// JSON Schema for arguments the model must provide.
98    #[serde(default = "default_tool_input_schema")]
99    pub input_schema: Value,
100    /// JSON Schema the function returns. Stored for docs/UI and validation by
101    /// convention; the function itself is still the authority at runtime.
102    #[serde(default = "default_tool_output_schema")]
103    pub output_schema: Value,
104    /// Loaded function name to invoke when this tool is called.
105    pub function: String,
106}
107
108fn default_tool_input_schema() -> Value {
109    json!({ "type": "object", "properties": {} })
110}
111
112fn default_tool_output_schema() -> Value {
113    json!({})
114}
115
116/// Per-agent access policy.
117#[derive(Debug, Clone, Deserialize)]
118#[serde(from = "AgentPermissionsRaw")]
119pub struct AgentPermissions {
120    /// Who may chat with this agent.
121    pub chat: Access,
122    /// Who may read stored history, through the generated resources.
123    pub history: Access,
124    /// Who may delete stored threads from history.
125    pub delete_history: Access,
126}
127
128impl Default for AgentPermissions {
129    fn default() -> Self {
130        AgentPermissions {
131            chat: Access::Authenticated,
132            history: Access::Owner,
133            delete_history: Access::Owner,
134        }
135    }
136}
137
138#[derive(Debug, Clone, Default, Deserialize)]
139#[serde(default)]
140struct AgentPermissionsRaw {
141    chat: Option<String>,
142    history: Option<String>,
143    delete_history: Option<String>,
144}
145
146impl From<AgentPermissionsRaw> for AgentPermissions {
147    fn from(raw: AgentPermissionsRaw) -> Self {
148        let default = AgentPermissions::default();
149        AgentPermissions {
150            chat: raw
151                .chat
152                .map(|value| Access::parse(&value))
153                .unwrap_or(default.chat),
154            history: raw
155                .history
156                .map(|value| Access::parse(&value))
157                .unwrap_or(default.history.clone()),
158            delete_history: raw
159                .delete_history
160                .map(|value| Access::parse(&value))
161                .unwrap_or(default.history),
162        }
163    }
164}
165
166impl Agent {
167    /// Load and validate a single agent file.
168    pub fn load(path: &Path) -> crate::Result<Self> {
169        let text = std::fs::read_to_string(path).map_err(|e| crate::Error::Io {
170            path: path.to_path_buf(),
171            source: e,
172        })?;
173        let source = path.file_name().unwrap_or_default().to_string_lossy();
174        let agent: Agent =
175            crate::env::parse_toml(&text, &source).map_err(|e| crate::Error::Toml {
176                path: path.to_path_buf(),
177                source: e,
178            })?;
179        agent.validate()?;
180        Ok(agent)
181    }
182
183    pub fn validate(&self) -> crate::Result<()> {
184        if self.meta.name.trim().is_empty() {
185            return Err(crate::Error::Schema {
186                resource: "agent".to_string(),
187                message: "[agent] name cannot be empty".to_string(),
188            });
189        }
190        if matches!(self.permissions.chat, Access::Owner) {
191            return Err(crate::Error::Schema {
192                resource: self.meta.name.clone(),
193                message: "[permissions] chat = \"owner\" is not valid for an agent".to_string(),
194            });
195        }
196        if self.meta.storage.enabled && matches!(self.permissions.chat, Access::Public) {
197            return Err(crate::Error::Schema {
198                resource: self.meta.name.clone(),
199                message:
200                    "a stored agent cannot be public because persisted history needs an authenticated owner"
201                        .to_string(),
202            });
203        }
204        if self.meta.storage.enabled && matches!(self.permissions.delete_history, Access::Public) {
205            return Err(crate::Error::Schema {
206                resource: self.meta.name.clone(),
207                message: "a stored agent cannot allow public history deletion".to_string(),
208            });
209        }
210        if self.meta.storage.enabled
211            && self.meta.scope == Scope::Global
212            && matches!(self.permissions.chat, Access::Member | Access::Role(_))
213        {
214            return Err(crate::Error::Schema {
215                resource: self.meta.name.clone(),
216                message:
217                    "a stored global agent cannot use `member` or `role:` chat access; use scope = \"organization\""
218                        .to_string(),
219            });
220        }
221        if self.meta.storage.enabled
222            && self.meta.scope == Scope::Global
223            && matches!(self.permissions.history, Access::Member | Access::Role(_))
224        {
225            return Err(crate::Error::Schema {
226                resource: self.meta.name.clone(),
227                message:
228                    "a stored global agent cannot use `member` or `role:` history access; use scope = \"organization\""
229                        .to_string(),
230            });
231        }
232        if self.meta.storage.enabled
233            && self.meta.scope == Scope::Global
234            && matches!(
235                self.permissions.delete_history,
236                Access::Member | Access::Role(_)
237            )
238        {
239            return Err(crate::Error::Schema {
240                resource: self.meta.name.clone(),
241                message:
242                    "a stored global agent cannot use `member` or `role:` delete_history access; use scope = \"organization\""
243                        .to_string(),
244            });
245        }
246        for tool in &self.tools {
247            if tool.name.trim().is_empty() {
248                return Err(crate::Error::Schema {
249                    resource: self.meta.name.clone(),
250                    message: "agent tools must have a non-empty name".to_string(),
251                });
252            }
253            if !tool
254                .name
255                .chars()
256                .all(|ch| ch.is_ascii_alphanumeric() || ch == '_' || ch == '-')
257            {
258                return Err(crate::Error::Schema {
259                    resource: self.meta.name.clone(),
260                    message: format!(
261                        "agent tool `{}` may only contain letters, digits, `_` or `-`",
262                        tool.name
263                    ),
264                });
265            }
266            if tool.function.trim().is_empty() {
267                return Err(crate::Error::Schema {
268                    resource: self.meta.name.clone(),
269                    message: format!("agent tool `{}` names an empty function", tool.name),
270                });
271            }
272            if !tool.input_schema.is_object() {
273                return Err(crate::Error::Schema {
274                    resource: self.meta.name.clone(),
275                    message: format!("agent tool `{}` input_schema must be a JSON object", tool.name),
276                });
277            }
278            if !tool.output_schema.is_object() {
279                return Err(crate::Error::Schema {
280                    resource: self.meta.name.clone(),
281                    message: format!("agent tool `{}` output_schema must be a JSON object", tool.name),
282                });
283            }
284        }
285        Ok(())
286    }
287
288    /// The app's `[ai]` configuration, with this agent's own overrides applied.
289    ///
290    /// The legacy `[agent] model|temperature|max_tokens` keys still work and
291    /// win over the fallback values they historically replaced.
292    pub fn merged_ai_config(&self, base: &AiConfig) -> AiConfig {
293        let mut merged = base.clone();
294        if let Some(ai) = &self.ai {
295            if let Some(provider) = &ai.provider {
296                merged.provider = provider.clone();
297            }
298            if let Some(endpoint) = &ai.endpoint {
299                merged.endpoint = endpoint.clone();
300            }
301            if let Some(model) = &ai.model {
302                merged.model = model.clone();
303            }
304            if let Some(api_key) = &ai.api_key {
305                merged.api_key = api_key.clone();
306            }
307            if let Some(system) = &ai.system {
308                merged.system = system.clone();
309            }
310            if let Some(max_tokens) = ai.max_tokens {
311                merged.max_tokens = max_tokens;
312            }
313            if let Some(temperature) = ai.temperature {
314                merged.temperature = temperature;
315            }
316            if let Some(timeout_secs) = ai.timeout_secs {
317                merged.timeout_secs = timeout_secs;
318            }
319        }
320        if let Some(model) = &self.meta.model {
321            merged.model = model.clone();
322        }
323        if let Some(temperature) = self.meta.temperature {
324            merged.temperature = temperature;
325        }
326        if let Some(max_tokens) = self.meta.max_tokens {
327            merged.max_tokens = max_tokens;
328        }
329        merged
330    }
331
332    /// Human label for a configured agent.
333    pub fn label(&self) -> String {
334        titleize(&self.meta.name)
335    }
336
337    /// The generated resource that stores conversation threads, if history is on.
338    pub fn thread_resource_name(&self) -> String {
339        format!("ai_{}_thread", self.meta.name)
340    }
341
342    /// The generated resource that stores persisted messages, if history is on.
343    pub fn message_resource_name(&self) -> String {
344        format!("ai_{}_message", self.meta.name)
345    }
346
347    /// The two generated resources backing persisted history.
348    pub fn storage_resources(&self) -> crate::Result<BTreeMap<String, Resource>> {
349        let mut resources = BTreeMap::new();
350        if !self.meta.storage.enabled {
351            return Ok(resources);
352        }
353
354        let scope = match self.meta.scope {
355            Scope::Global => "global",
356            Scope::Organization => "organization",
357        };
358        let history = self.permissions.history.as_string();
359        let delete_history = self.permissions.delete_history.as_string();
360        let label = self.label();
361        let thread_name = self.thread_resource_name();
362        let message_name = self.message_resource_name();
363
364        let thread = format!(
365            r#"
366[resource]
367name = "{thread_name}"
368scope = "{scope}"
369timestamps = true
370
371[admin]
372label = "{label} thread"
373plural = "{label} threads"
374visible = false
375
376[permissions]
377list   = "{history}"
378read   = "{history}"
379create = "private"
380update = "private"
381delete = "{delete_history}"
382
383[fields.owner_id]
384type = "reference"
385references = "user"
386required = true
387on_delete = "cascade"
388
389[fields.title]
390type = "string"
391max_length = 200
392"#
393        );
394        let message = format!(
395            r#"
396[resource]
397name = "{message_name}"
398scope = "{scope}"
399timestamps = true
400
401[admin]
402label = "{label} message"
403plural = "{label} messages"
404visible = false
405
406[permissions]
407list   = "{history}"
408read   = "{history}"
409create = "private"
410update = "private"
411delete = "private"
412
413[fields.thread_id]
414type = "reference"
415references = "{thread_name}"
416required = true
417on_delete = "cascade"
418
419[fields.owner_id]
420type = "reference"
421references = "user"
422required = true
423on_delete = "cascade"
424
425[fields.role]
426type = "string"
427required = true
428
429[fields.content]
430type = "text"
431required = true
432
433[fields.tool_call_id]
434type = "string"
435
436[fields.tool_name]
437type = "string"
438
439[fields.tool_input]
440type = "json"
441
442[fields.tool_output]
443type = "json"
444
445[fields.provider]
446type = "string"
447
448[fields.model]
449type = "string"
450
451[fields.finish_reason]
452type = "string"
453
454[fields.input_tokens]
455type = "integer"
456
457[fields.output_tokens]
458type = "integer"
459"#
460        );
461
462        for src in [thread, message] {
463            let resource: Resource = toml::from_str(&src).map_err(|source| crate::Error::Toml {
464                path: Path::new("<generated agent resource>").to_path_buf(),
465                source,
466            })?;
467            resource.validate()?;
468            resources.insert(resource.meta.name.clone(), resource);
469        }
470
471        Ok(resources)
472    }
473}
474
475#[cfg(test)]
476mod tests {
477    use super::*;
478
479    fn parse(src: &str) -> Agent {
480        let agent: Agent = toml::from_str(src).unwrap();
481        agent.validate().unwrap();
482        agent
483    }
484
485    #[test]
486    fn stored_agents_generate_thread_and_message_resources() {
487        let agent = parse(
488            r#"
489[agent]
490name = "coach"
491storage.enabled = true
492
493[permissions]
494chat = "authenticated"
495history = "owner"
496"#,
497        );
498
499        let resources = agent.storage_resources().unwrap();
500        assert!(resources.contains_key("ai_coach_thread"));
501        assert!(resources.contains_key("ai_coach_message"));
502        assert_eq!(
503            resources["ai_coach_thread"].permissions.delete.as_string(),
504            "owner"
505        );
506    }
507
508    #[test]
509    fn stored_agents_may_override_history_deletion_access() {
510        let agent = parse(
511            r#"
512[agent]
513name = "coach"
514scope = "organization"
515storage.enabled = true
516
517[permissions]
518chat = "authenticated"
519history = "owner"
520delete_history = "role:admin"
521"#,
522        );
523
524        let resources = agent.storage_resources().unwrap();
525        assert_eq!(
526            resources["ai_coach_thread"].permissions.delete.as_string(),
527            "role:admin"
528        );
529    }
530
531    #[test]
532    fn a_stored_agent_cannot_be_public() {
533        let agent: Agent = toml::from_str(
534            r#"
535[agent]
536name = "coach"
537storage.enabled = true
538
539[permissions]
540chat = "public"
541"#,
542        )
543        .unwrap();
544        assert!(agent.validate().is_err());
545    }
546
547    #[test]
548    fn an_agent_can_override_the_app_ai_config() {
549        let agent = parse(
550            r#"
551[agent]
552name = "coach"
553
554[ai]
555provider = "custom"
556endpoint = "http://localhost:8080"
557api_key = ""
558model = "local"
559temperature = 0.2
560timeout_secs = 15
561"#,
562        );
563
564        let base = AiConfig {
565            provider: "openai".to_string(),
566            endpoint: String::new(),
567            model: "gpt-4o-mini".to_string(),
568            api_key: "$OPENAI_API_KEY".to_string(),
569            system: "base".to_string(),
570            max_tokens: 2048,
571            temperature: -1.0,
572            access: "authenticated".to_string(),
573            timeout_secs: 300,
574        };
575
576        let merged = agent.merged_ai_config(&base);
577        assert_eq!(merged.provider, "custom");
578        assert_eq!(merged.endpoint, "http://localhost:8080");
579        assert_eq!(merged.model, "local");
580        assert_eq!(merged.api_key, "");
581        assert_eq!(merged.timeout_secs, 15);
582        assert_eq!(merged.access, "authenticated");
583    }
584}