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, Policy, 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: Policy,
135    /// Who may read stored history, through the generated resources.
136    pub history: Policy,
137    /// Who may delete stored threads from history.
138    pub delete_history: Policy,
139}
140
141impl Default for AgentPermissions {
142    fn default() -> Self {
143        AgentPermissions {
144            chat: Access::Authenticated.into(),
145            history: Access::Owner.into(),
146            delete_history: Access::Owner.into(),
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| Policy::parse(&value))
166                .unwrap_or(default.chat),
167            history: raw
168                .history
169                .map(|value| Policy::parse(&value))
170                .unwrap_or(default.history.clone()),
171            delete_history: raw
172                .delete_history
173                .map(|value| Policy::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.level, 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.level, 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
218            && matches!(self.permissions.delete_history.level, Access::Public)
219        {
220            return Err(crate::Error::Schema {
221                resource: self.meta.name.clone(),
222                message: "a stored agent cannot allow public history deletion".to_string(),
223            });
224        }
225        if self.meta.storage.enabled
226            && self.meta.scope == Scope::Global
227            && matches!(
228                self.permissions.chat.level,
229                Access::Member | Access::Role(_)
230            )
231        {
232            return Err(crate::Error::Schema {
233                resource: self.meta.name.clone(),
234                message:
235                    "a stored global agent cannot use `member` or `role:` chat access; use scope = \"organization\""
236                        .to_string(),
237            });
238        }
239        if self.meta.storage.enabled
240            && self.meta.scope == Scope::Global
241            && matches!(
242                self.permissions.history.level,
243                Access::Member | Access::Role(_)
244            )
245        {
246            return Err(crate::Error::Schema {
247                resource: self.meta.name.clone(),
248                message:
249                    "a stored global agent cannot use `member` or `role:` history access; use scope = \"organization\""
250                        .to_string(),
251            });
252        }
253        if self.meta.storage.enabled
254            && self.meta.scope == Scope::Global
255            && matches!(
256                self.permissions.delete_history.level,
257                Access::Member | Access::Role(_)
258            )
259        {
260            return Err(crate::Error::Schema {
261                resource: self.meta.name.clone(),
262                message:
263                    "a stored global agent cannot use `member` or `role:` delete_history access; use scope = \"organization\""
264                        .to_string(),
265            });
266        }
267        for tool in &self.tools {
268            if tool.name.trim().is_empty() {
269                return Err(crate::Error::Schema {
270                    resource: self.meta.name.clone(),
271                    message: "agent tools must have a non-empty name".to_string(),
272                });
273            }
274            if !tool
275                .name
276                .chars()
277                .all(|ch| ch.is_ascii_alphanumeric() || ch == '_' || ch == '-')
278            {
279                return Err(crate::Error::Schema {
280                    resource: self.meta.name.clone(),
281                    message: format!(
282                        "agent tool `{}` may only contain letters, digits, `_` or `-`",
283                        tool.name
284                    ),
285                });
286            }
287            if tool.function.trim().is_empty() {
288                return Err(crate::Error::Schema {
289                    resource: self.meta.name.clone(),
290                    message: format!("agent tool `{}` names an empty function", tool.name),
291                });
292            }
293            if !tool.input_schema.is_object() {
294                return Err(crate::Error::Schema {
295                    resource: self.meta.name.clone(),
296                    message: format!(
297                        "agent tool `{}` input_schema must be a JSON object",
298                        tool.name
299                    ),
300                });
301            }
302            if !tool.output_schema.is_object() {
303                return Err(crate::Error::Schema {
304                    resource: self.meta.name.clone(),
305                    message: format!(
306                        "agent tool `{}` output_schema must be a JSON object",
307                        tool.name
308                    ),
309                });
310            }
311        }
312        Ok(())
313    }
314
315    /// The app's `[ai]` configuration, with this agent's own overrides applied.
316    ///
317    /// The legacy `[agent] model|temperature|max_tokens` keys still work and
318    /// win over the fallback values they historically replaced.
319    pub fn merged_ai_config(&self, base: &AiConfig) -> AiConfig {
320        let mut merged = base.clone();
321        if let Some(ai) = &self.ai {
322            if let Some(provider) = &ai.provider {
323                merged.provider = provider.clone();
324            }
325            if let Some(endpoint) = &ai.endpoint {
326                merged.endpoint = endpoint.clone();
327            }
328            if let Some(model) = &ai.model {
329                merged.model = model.clone();
330            }
331            if let Some(api_key) = &ai.api_key {
332                merged.api_key = api_key.clone();
333            }
334            if let Some(system) = &ai.system {
335                merged.system = system.clone();
336            }
337            if let Some(max_tokens) = ai.max_tokens {
338                merged.max_tokens = max_tokens;
339            }
340            if let Some(temperature) = ai.temperature {
341                merged.temperature = temperature;
342            }
343            if let Some(reasoning) = ai.reasoning {
344                merged.reasoning = reasoning;
345            }
346            if let Some(thinking) = ai.thinking {
347                merged.thinking = Some(thinking);
348            }
349            if let Some(timeout_secs) = ai.timeout_secs {
350                merged.timeout_secs = timeout_secs;
351            }
352        }
353        if let Some(model) = &self.meta.model {
354            merged.model = model.clone();
355        }
356        if let Some(temperature) = self.meta.temperature {
357            merged.temperature = temperature;
358        }
359        if let Some(max_tokens) = self.meta.max_tokens {
360            merged.max_tokens = max_tokens;
361        }
362        merged
363    }
364
365    /// Human label for a configured agent.
366    pub fn label(&self) -> String {
367        titleize(&self.meta.name)
368    }
369
370    /// The generated resource that stores conversation threads, if history is on.
371    pub fn thread_resource_name(&self) -> String {
372        format!("ai_{}_thread", self.meta.name)
373    }
374
375    /// The generated resource that stores persisted messages, if history is on.
376    pub fn message_resource_name(&self) -> String {
377        format!("ai_{}_message", self.meta.name)
378    }
379
380    /// The two generated resources backing persisted history.
381    pub fn storage_resources(&self) -> crate::Result<BTreeMap<String, Resource>> {
382        let mut resources = BTreeMap::new();
383        if !self.meta.storage.enabled {
384            return Ok(resources);
385        }
386
387        let scope = match self.meta.scope {
388            Scope::Global => "global",
389            Scope::Organization => "organization",
390        };
391        let history = self.permissions.history.as_string();
392        let delete_history = self.permissions.delete_history.as_string();
393        let label = self.label();
394        let thread_name = self.thread_resource_name();
395        let message_name = self.message_resource_name();
396
397        let thread = format!(
398            r#"
399[resource]
400name = "{thread_name}"
401scope = "{scope}"
402timestamps = true
403
404[admin]
405label = "{label} thread"
406plural = "{label} threads"
407visible = false
408
409[permissions]
410list   = "{history}"
411read   = "{history}"
412create = "private"
413update = "private"
414delete = "{delete_history}"
415
416[fields.owner_id]
417type = "reference"
418references = "user"
419required = true
420on_delete = "cascade"
421
422[fields.title]
423type = "string"
424max_length = 200
425
426[fields.summary]
427type = "text"
428hidden = true
429
430[fields.summary_message_count]
431type = "integer"
432hidden = true
433
434[fields.summary_characters]
435type = "integer"
436hidden = true
437
438[fields.summary_updated_at]
439type = "timestamp"
440hidden = true
441"#
442        );
443        let message = format!(
444            r#"
445[resource]
446name = "{message_name}"
447scope = "{scope}"
448timestamps = true
449
450[admin]
451label = "{label} message"
452plural = "{label} messages"
453visible = false
454
455[permissions]
456list   = "{history}"
457read   = "{history}"
458create = "private"
459update = "private"
460delete = "private"
461
462[fields.thread_id]
463type = "reference"
464references = "{thread_name}"
465required = true
466on_delete = "cascade"
467
468[fields.owner_id]
469type = "reference"
470references = "user"
471required = true
472on_delete = "cascade"
473
474[fields.role]
475type = "string"
476required = true
477
478[fields.content]
479type = "text"
480required = true
481
482[fields.reasoning]
483type = "text"
484
485[fields.tool_call_id]
486type = "string"
487
488[fields.tool_name]
489type = "string"
490
491[fields.tool_input]
492type = "json"
493
494[fields.tool_output]
495type = "json"
496
497[fields.provider]
498type = "string"
499
500[fields.model]
501type = "string"
502
503[fields.finish_reason]
504type = "string"
505
506[fields.input_tokens]
507type = "integer"
508
509[fields.output_tokens]
510type = "integer"
511"#
512        );
513
514        for src in [thread, message] {
515            let resource: Resource = toml::from_str(&src).map_err(|source| crate::Error::Toml {
516                path: Path::new("<generated agent resource>").to_path_buf(),
517                source,
518            })?;
519            resource.validate()?;
520            resources.insert(resource.meta.name.clone(), resource);
521        }
522
523        Ok(resources)
524    }
525}
526
527#[cfg(test)]
528mod tests {
529    use super::*;
530
531    fn parse(src: &str) -> Agent {
532        let agent: Agent = toml::from_str(src).unwrap();
533        agent.validate().unwrap();
534        agent
535    }
536
537    #[test]
538    fn stored_agents_generate_thread_and_message_resources() {
539        let agent = parse(
540            r#"
541[agent]
542name = "coach"
543storage.enabled = true
544
545[permissions]
546chat = "authenticated"
547history = "owner"
548"#,
549        );
550
551        let resources = agent.storage_resources().unwrap();
552        assert!(resources.contains_key("ai_coach_thread"));
553        assert!(resources.contains_key("ai_coach_message"));
554        assert!(resources["ai_coach_thread"].fields["summary"].hidden);
555        assert!(resources["ai_coach_thread"].fields["summary_updated_at"].hidden);
556        assert_eq!(
557            resources["ai_coach_thread"].permissions.delete.as_string(),
558            "owner"
559        );
560    }
561
562    #[test]
563    fn stored_agents_may_override_history_deletion_access() {
564        let agent = parse(
565            r#"
566[agent]
567name = "coach"
568scope = "organization"
569storage.enabled = true
570
571[permissions]
572chat = "authenticated"
573history = "owner"
574delete_history = "role:admin"
575"#,
576        );
577
578        let resources = agent.storage_resources().unwrap();
579        assert_eq!(
580            resources["ai_coach_thread"].permissions.delete.as_string(),
581            "role:admin"
582        );
583    }
584
585    #[test]
586    fn stored_agents_may_override_summary_thresholds() {
587        let agent = parse(
588            r#"
589[agent]
590name = "coach"
591storage.enabled = true
592storage.summary_after_characters = 1200
593"#,
594        );
595
596        assert_eq!(agent.meta.storage.summary_after_characters(12_000), 1200);
597    }
598
599    #[test]
600    fn a_stored_agent_cannot_be_public() {
601        let agent: Agent = toml::from_str(
602            r#"
603[agent]
604name = "coach"
605storage.enabled = true
606
607[permissions]
608chat = "public"
609"#,
610        )
611        .unwrap();
612        assert!(agent.validate().is_err());
613    }
614
615    #[test]
616    fn an_agent_can_override_the_app_ai_config() {
617        let agent = parse(
618            r#"
619[agent]
620name = "coach"
621
622[ai]
623provider = "custom"
624endpoint = "http://localhost:8080"
625api_key = ""
626model = "local"
627temperature = 0.2
628timeout_secs = 15
629reasoning = true
630thinking = false
631"#,
632        );
633
634        let base = AiConfig {
635            provider: "openai".to_string(),
636            endpoint: String::new(),
637            model: "gpt-4o-mini".to_string(),
638            api_key: "$OPENAI_API_KEY".to_string(),
639            system: "base".to_string(),
640            max_tokens: 2048,
641            temperature: -1.0,
642            access: "authenticated".to_string(),
643            reasoning: false,
644            thinking: None,
645            timeout_secs: 300,
646        };
647
648        let merged = agent.merged_ai_config(&base);
649        assert_eq!(merged.provider, "custom");
650        assert_eq!(merged.endpoint, "http://localhost:8080");
651        assert_eq!(merged.model, "local");
652        assert_eq!(merged.api_key, "");
653        assert_eq!(merged.timeout_secs, 15);
654        assert_eq!(merged.access, "authenticated");
655        assert!(merged.reasoning);
656        // Showing the thinking and asking for it are separate decisions.
657        assert_eq!(merged.thinking, Some(false));
658    }
659}