1use 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#[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: Policy,
135 pub history: Policy,
137 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 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 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 pub fn label(&self) -> String {
367 titleize(&self.meta.name)
368 }
369
370 pub fn thread_resource_name(&self) -> String {
372 format!("ai_{}_thread", self.meta.name)
373 }
374
375 pub fn message_resource_name(&self) -> String {
377 format!("ai_{}_message", self.meta.name)
378 }
379
380 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 assert_eq!(merged.thinking, Some(false));
658 }
659}