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    /// Refresh the rolling summary once the unsummarised tail crosses this
73    /// many characters.
74    pub summary_after_characters: Option<u32>,
75}
76
77impl AgentStorage {
78    pub fn summary_after_characters(&self, default: usize) -> usize {
79        self.summary_after_characters
80            .unwrap_or(default as u32)
81            .max(1) as usize
82    }
83}
84
85/// Optional per-agent AI client overrides, from a top-level `[ai]` table in
86/// the agent file.
87#[derive(Debug, Clone, Default, Deserialize)]
88#[serde(default)]
89pub struct AgentAiOverride {
90    pub provider: Option<String>,
91    pub endpoint: Option<String>,
92    pub model: Option<String>,
93    pub api_key: Option<String>,
94    pub system: Option<String>,
95    pub max_tokens: Option<u32>,
96    pub temperature: Option<f32>,
97    pub reasoning: Option<bool>,
98    pub thinking: Option<bool>,
99    pub timeout_secs: Option<u64>,
100}
101
102/// One function-backed tool an agent may ask the model to call.
103#[derive(Debug, Clone, Deserialize)]
104pub struct AgentTool {
105    /// Name exposed to the model.
106    pub name: String,
107    /// Human description passed to the model.
108    #[serde(default)]
109    pub description: String,
110    /// JSON Schema for arguments the model must provide.
111    #[serde(default = "default_tool_input_schema")]
112    pub input_schema: Value,
113    /// JSON Schema the function returns. Stored for docs/UI and validation by
114    /// convention; the function itself is still the authority at runtime.
115    #[serde(default = "default_tool_output_schema")]
116    pub output_schema: Value,
117    /// Loaded function name to invoke when this tool is called.
118    pub function: String,
119}
120
121fn default_tool_input_schema() -> Value {
122    json!({ "type": "object", "properties": {} })
123}
124
125fn default_tool_output_schema() -> Value {
126    json!({})
127}
128
129/// Per-agent access policy.
130#[derive(Debug, Clone, Deserialize)]
131#[serde(from = "AgentPermissionsRaw")]
132pub struct AgentPermissions {
133    /// Who may chat with this agent.
134    pub chat: Access,
135    /// Who may read stored history, through the generated resources.
136    pub history: Access,
137    /// Who may delete stored threads from history.
138    pub delete_history: Access,
139}
140
141impl Default for AgentPermissions {
142    fn default() -> Self {
143        AgentPermissions {
144            chat: Access::Authenticated,
145            history: Access::Owner,
146            delete_history: Access::Owner,
147        }
148    }
149}
150
151#[derive(Debug, Clone, Default, Deserialize)]
152#[serde(default)]
153struct AgentPermissionsRaw {
154    chat: Option<String>,
155    history: Option<String>,
156    delete_history: Option<String>,
157}
158
159impl From<AgentPermissionsRaw> for AgentPermissions {
160    fn from(raw: AgentPermissionsRaw) -> Self {
161        let default = AgentPermissions::default();
162        AgentPermissions {
163            chat: raw
164                .chat
165                .map(|value| Access::parse(&value))
166                .unwrap_or(default.chat),
167            history: raw
168                .history
169                .map(|value| Access::parse(&value))
170                .unwrap_or(default.history.clone()),
171            delete_history: raw
172                .delete_history
173                .map(|value| Access::parse(&value))
174                .unwrap_or(default.history),
175        }
176    }
177}
178
179impl Agent {
180    /// Load and validate a single agent file.
181    pub fn load(path: &Path) -> crate::Result<Self> {
182        let text = std::fs::read_to_string(path).map_err(|e| crate::Error::Io {
183            path: path.to_path_buf(),
184            source: e,
185        })?;
186        let source = path.file_name().unwrap_or_default().to_string_lossy();
187        let agent: Agent =
188            crate::env::parse_toml(&text, &source).map_err(|e| crate::Error::Toml {
189                path: path.to_path_buf(),
190                source: e,
191            })?;
192        agent.validate()?;
193        Ok(agent)
194    }
195
196    pub fn validate(&self) -> crate::Result<()> {
197        if self.meta.name.trim().is_empty() {
198            return Err(crate::Error::Schema {
199                resource: "agent".to_string(),
200                message: "[agent] name cannot be empty".to_string(),
201            });
202        }
203        if matches!(self.permissions.chat, Access::Owner) {
204            return Err(crate::Error::Schema {
205                resource: self.meta.name.clone(),
206                message: "[permissions] chat = \"owner\" is not valid for an agent".to_string(),
207            });
208        }
209        if self.meta.storage.enabled && matches!(self.permissions.chat, Access::Public) {
210            return Err(crate::Error::Schema {
211                resource: self.meta.name.clone(),
212                message:
213                    "a stored agent cannot be public because persisted history needs an authenticated owner"
214                        .to_string(),
215            });
216        }
217        if self.meta.storage.enabled && matches!(self.permissions.delete_history, Access::Public) {
218            return Err(crate::Error::Schema {
219                resource: self.meta.name.clone(),
220                message: "a stored agent cannot allow public history deletion".to_string(),
221            });
222        }
223        if self.meta.storage.enabled
224            && self.meta.scope == Scope::Global
225            && matches!(self.permissions.chat, Access::Member | Access::Role(_))
226        {
227            return Err(crate::Error::Schema {
228                resource: self.meta.name.clone(),
229                message:
230                    "a stored global agent cannot use `member` or `role:` chat access; use scope = \"organization\""
231                        .to_string(),
232            });
233        }
234        if self.meta.storage.enabled
235            && self.meta.scope == Scope::Global
236            && matches!(self.permissions.history, Access::Member | Access::Role(_))
237        {
238            return Err(crate::Error::Schema {
239                resource: self.meta.name.clone(),
240                message:
241                    "a stored global agent cannot use `member` or `role:` history access; use scope = \"organization\""
242                        .to_string(),
243            });
244        }
245        if self.meta.storage.enabled
246            && self.meta.scope == Scope::Global
247            && matches!(
248                self.permissions.delete_history,
249                Access::Member | Access::Role(_)
250            )
251        {
252            return Err(crate::Error::Schema {
253                resource: self.meta.name.clone(),
254                message:
255                    "a stored global agent cannot use `member` or `role:` delete_history access; use scope = \"organization\""
256                        .to_string(),
257            });
258        }
259        for tool in &self.tools {
260            if tool.name.trim().is_empty() {
261                return Err(crate::Error::Schema {
262                    resource: self.meta.name.clone(),
263                    message: "agent tools must have a non-empty name".to_string(),
264                });
265            }
266            if !tool
267                .name
268                .chars()
269                .all(|ch| ch.is_ascii_alphanumeric() || ch == '_' || ch == '-')
270            {
271                return Err(crate::Error::Schema {
272                    resource: self.meta.name.clone(),
273                    message: format!(
274                        "agent tool `{}` may only contain letters, digits, `_` or `-`",
275                        tool.name
276                    ),
277                });
278            }
279            if tool.function.trim().is_empty() {
280                return Err(crate::Error::Schema {
281                    resource: self.meta.name.clone(),
282                    message: format!("agent tool `{}` names an empty function", tool.name),
283                });
284            }
285            if !tool.input_schema.is_object() {
286                return Err(crate::Error::Schema {
287                    resource: self.meta.name.clone(),
288                    message: format!(
289                        "agent tool `{}` input_schema must be a JSON object",
290                        tool.name
291                    ),
292                });
293            }
294            if !tool.output_schema.is_object() {
295                return Err(crate::Error::Schema {
296                    resource: self.meta.name.clone(),
297                    message: format!(
298                        "agent tool `{}` output_schema must be a JSON object",
299                        tool.name
300                    ),
301                });
302            }
303        }
304        Ok(())
305    }
306
307    /// The app's `[ai]` configuration, with this agent's own overrides applied.
308    ///
309    /// The legacy `[agent] model|temperature|max_tokens` keys still work and
310    /// win over the fallback values they historically replaced.
311    pub fn merged_ai_config(&self, base: &AiConfig) -> AiConfig {
312        let mut merged = base.clone();
313        if let Some(ai) = &self.ai {
314            if let Some(provider) = &ai.provider {
315                merged.provider = provider.clone();
316            }
317            if let Some(endpoint) = &ai.endpoint {
318                merged.endpoint = endpoint.clone();
319            }
320            if let Some(model) = &ai.model {
321                merged.model = model.clone();
322            }
323            if let Some(api_key) = &ai.api_key {
324                merged.api_key = api_key.clone();
325            }
326            if let Some(system) = &ai.system {
327                merged.system = system.clone();
328            }
329            if let Some(max_tokens) = ai.max_tokens {
330                merged.max_tokens = max_tokens;
331            }
332            if let Some(temperature) = ai.temperature {
333                merged.temperature = temperature;
334            }
335            if let Some(reasoning) = ai.reasoning {
336                merged.reasoning = reasoning;
337            }
338            if let Some(thinking) = ai.thinking {
339                merged.thinking = Some(thinking);
340            }
341            if let Some(timeout_secs) = ai.timeout_secs {
342                merged.timeout_secs = timeout_secs;
343            }
344        }
345        if let Some(model) = &self.meta.model {
346            merged.model = model.clone();
347        }
348        if let Some(temperature) = self.meta.temperature {
349            merged.temperature = temperature;
350        }
351        if let Some(max_tokens) = self.meta.max_tokens {
352            merged.max_tokens = max_tokens;
353        }
354        merged
355    }
356
357    /// Human label for a configured agent.
358    pub fn label(&self) -> String {
359        titleize(&self.meta.name)
360    }
361
362    /// The generated resource that stores conversation threads, if history is on.
363    pub fn thread_resource_name(&self) -> String {
364        format!("ai_{}_thread", self.meta.name)
365    }
366
367    /// The generated resource that stores persisted messages, if history is on.
368    pub fn message_resource_name(&self) -> String {
369        format!("ai_{}_message", self.meta.name)
370    }
371
372    /// The two generated resources backing persisted history.
373    pub fn storage_resources(&self) -> crate::Result<BTreeMap<String, Resource>> {
374        let mut resources = BTreeMap::new();
375        if !self.meta.storage.enabled {
376            return Ok(resources);
377        }
378
379        let scope = match self.meta.scope {
380            Scope::Global => "global",
381            Scope::Organization => "organization",
382        };
383        let history = self.permissions.history.as_string();
384        let delete_history = self.permissions.delete_history.as_string();
385        let label = self.label();
386        let thread_name = self.thread_resource_name();
387        let message_name = self.message_resource_name();
388
389        let thread = format!(
390            r#"
391[resource]
392name = "{thread_name}"
393scope = "{scope}"
394timestamps = true
395
396[admin]
397label = "{label} thread"
398plural = "{label} threads"
399visible = false
400
401[permissions]
402list   = "{history}"
403read   = "{history}"
404create = "private"
405update = "private"
406delete = "{delete_history}"
407
408[fields.owner_id]
409type = "reference"
410references = "user"
411required = true
412on_delete = "cascade"
413
414[fields.title]
415type = "string"
416max_length = 200
417
418[fields.summary]
419type = "text"
420hidden = true
421
422[fields.summary_message_count]
423type = "integer"
424hidden = true
425
426[fields.summary_characters]
427type = "integer"
428hidden = true
429
430[fields.summary_updated_at]
431type = "timestamp"
432hidden = true
433"#
434        );
435        let message = format!(
436            r#"
437[resource]
438name = "{message_name}"
439scope = "{scope}"
440timestamps = true
441
442[admin]
443label = "{label} message"
444plural = "{label} messages"
445visible = false
446
447[permissions]
448list   = "{history}"
449read   = "{history}"
450create = "private"
451update = "private"
452delete = "private"
453
454[fields.thread_id]
455type = "reference"
456references = "{thread_name}"
457required = true
458on_delete = "cascade"
459
460[fields.owner_id]
461type = "reference"
462references = "user"
463required = true
464on_delete = "cascade"
465
466[fields.role]
467type = "string"
468required = true
469
470[fields.content]
471type = "text"
472required = true
473
474[fields.reasoning]
475type = "text"
476
477[fields.tool_call_id]
478type = "string"
479
480[fields.tool_name]
481type = "string"
482
483[fields.tool_input]
484type = "json"
485
486[fields.tool_output]
487type = "json"
488
489[fields.provider]
490type = "string"
491
492[fields.model]
493type = "string"
494
495[fields.finish_reason]
496type = "string"
497
498[fields.input_tokens]
499type = "integer"
500
501[fields.output_tokens]
502type = "integer"
503"#
504        );
505
506        for src in [thread, message] {
507            let resource: Resource = toml::from_str(&src).map_err(|source| crate::Error::Toml {
508                path: Path::new("<generated agent resource>").to_path_buf(),
509                source,
510            })?;
511            resource.validate()?;
512            resources.insert(resource.meta.name.clone(), resource);
513        }
514
515        Ok(resources)
516    }
517}
518
519#[cfg(test)]
520mod tests {
521    use super::*;
522
523    fn parse(src: &str) -> Agent {
524        let agent: Agent = toml::from_str(src).unwrap();
525        agent.validate().unwrap();
526        agent
527    }
528
529    #[test]
530    fn stored_agents_generate_thread_and_message_resources() {
531        let agent = parse(
532            r#"
533[agent]
534name = "coach"
535storage.enabled = true
536
537[permissions]
538chat = "authenticated"
539history = "owner"
540"#,
541        );
542
543        let resources = agent.storage_resources().unwrap();
544        assert!(resources.contains_key("ai_coach_thread"));
545        assert!(resources.contains_key("ai_coach_message"));
546        assert!(resources["ai_coach_thread"].fields["summary"].hidden);
547        assert!(resources["ai_coach_thread"].fields["summary_updated_at"].hidden);
548        assert_eq!(
549            resources["ai_coach_thread"].permissions.delete.as_string(),
550            "owner"
551        );
552    }
553
554    #[test]
555    fn stored_agents_may_override_history_deletion_access() {
556        let agent = parse(
557            r#"
558[agent]
559name = "coach"
560scope = "organization"
561storage.enabled = true
562
563[permissions]
564chat = "authenticated"
565history = "owner"
566delete_history = "role:admin"
567"#,
568        );
569
570        let resources = agent.storage_resources().unwrap();
571        assert_eq!(
572            resources["ai_coach_thread"].permissions.delete.as_string(),
573            "role:admin"
574        );
575    }
576
577    #[test]
578    fn stored_agents_may_override_summary_thresholds() {
579        let agent = parse(
580            r#"
581[agent]
582name = "coach"
583storage.enabled = true
584storage.summary_after_characters = 1200
585"#,
586        );
587
588        assert_eq!(agent.meta.storage.summary_after_characters(12_000), 1200);
589    }
590
591    #[test]
592    fn a_stored_agent_cannot_be_public() {
593        let agent: Agent = toml::from_str(
594            r#"
595[agent]
596name = "coach"
597storage.enabled = true
598
599[permissions]
600chat = "public"
601"#,
602        )
603        .unwrap();
604        assert!(agent.validate().is_err());
605    }
606
607    #[test]
608    fn an_agent_can_override_the_app_ai_config() {
609        let agent = parse(
610            r#"
611[agent]
612name = "coach"
613
614[ai]
615provider = "custom"
616endpoint = "http://localhost:8080"
617api_key = ""
618model = "local"
619temperature = 0.2
620timeout_secs = 15
621reasoning = true
622thinking = false
623"#,
624        );
625
626        let base = AiConfig {
627            provider: "openai".to_string(),
628            endpoint: String::new(),
629            model: "gpt-4o-mini".to_string(),
630            api_key: "$OPENAI_API_KEY".to_string(),
631            system: "base".to_string(),
632            max_tokens: 2048,
633            temperature: -1.0,
634            access: "authenticated".to_string(),
635            reasoning: false,
636            thinking: None,
637            timeout_secs: 300,
638        };
639
640        let merged = agent.merged_ai_config(&base);
641        assert_eq!(merged.provider, "custom");
642        assert_eq!(merged.endpoint, "http://localhost:8080");
643        assert_eq!(merged.model, "local");
644        assert_eq!(merged.api_key, "");
645        assert_eq!(merged.timeout_secs, 15);
646        assert_eq!(merged.access, "authenticated");
647        assert!(merged.reasoning);
648        // Showing the thinking and asking for it are separate decisions.
649        assert_eq!(merged.thinking, Some(false));
650    }
651}