1use 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#[derive(Debug, Clone, Deserialize)]
18pub struct Agent {
19 #[serde(rename = "agent")]
20 pub meta: AgentMeta,
21 #[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#[derive(Debug, Clone, Deserialize)]
33pub struct AgentMeta {
34 pub name: String,
35 #[serde(default)]
37 pub description: String,
38 #[serde(default)]
40 pub system: String,
41 #[serde(default)]
47 pub model: Option<String>,
48 #[serde(default)]
50 pub temperature: Option<f32>,
51 #[serde(default)]
53 pub max_tokens: Option<u32>,
54 #[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#[derive(Debug, Clone, Default, Deserialize)]
68#[serde(default)]
69pub struct AgentStorage {
70 pub enabled: bool,
72 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#[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#[derive(Debug, Clone, Deserialize)]
104pub struct AgentTool {
105 pub name: String,
107 #[serde(default)]
109 pub description: String,
110 #[serde(default = "default_tool_input_schema")]
112 pub input_schema: Value,
113 #[serde(default = "default_tool_output_schema")]
116 pub output_schema: Value,
117 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#[derive(Debug, Clone, Deserialize)]
131#[serde(from = "AgentPermissionsRaw")]
132pub struct AgentPermissions {
133 pub chat: Access,
135 pub history: Access,
137 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 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 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 pub fn label(&self) -> String {
359 titleize(&self.meta.name)
360 }
361
362 pub fn thread_resource_name(&self) -> String {
364 format!("ai_{}_thread", self.meta.name)
365 }
366
367 pub fn message_resource_name(&self) -> String {
369 format!("ai_{}_message", self.meta.name)
370 }
371
372 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 assert_eq!(merged.thinking, Some(false));
650 }
651}