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}
73
74#[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#[derive(Debug, Clone, Deserialize)]
91pub struct AgentTool {
92 pub name: String,
94 #[serde(default)]
96 pub description: String,
97 #[serde(default = "default_tool_input_schema")]
99 pub input_schema: Value,
100 #[serde(default = "default_tool_output_schema")]
103 pub output_schema: Value,
104 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#[derive(Debug, Clone, Deserialize)]
118#[serde(from = "AgentPermissionsRaw")]
119pub struct AgentPermissions {
120 pub chat: Access,
122 pub history: Access,
124 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 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 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 pub fn label(&self) -> String {
334 titleize(&self.meta.name)
335 }
336
337 pub fn thread_resource_name(&self) -> String {
339 format!("ai_{}_thread", self.meta.name)
340 }
341
342 pub fn message_resource_name(&self) -> String {
344 format!("ai_{}_message", self.meta.name)
345 }
346
347 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}