1pub const PERSONALITY_PROMPT_MAX_BYTES: usize = 16 * 1024;
5
6const PERSONALITY_FINGERPRINT_DOMAIN: &[u8] = b"agentty-personality-v1";
8
9const FNV_1A_OFFSET_BASIS: u64 = 0xcbf2_9ce4_8422_2325;
11
12const FNV_1A_PRIME: u64 = 0x0000_0100_0000_01b3;
14
15const PERSONALITY_PROMPT_TRUNCATION_MARKER: &str = "\n\n[Personality prompt truncated at 16 KiB.]";
17
18#[derive(Clone, Debug, Eq, PartialEq)]
20pub struct Personality {
21 pub description: String,
23 pub id: String,
25 pub name: String,
27 pub prompt: String,
29}
30
31impl Personality {
32 #[must_use]
34 pub fn summary(&self) -> PersonalitySummary {
35 PersonalitySummary {
36 description: self.description.clone(),
37 id: self.id.clone(),
38 name: self.name.clone(),
39 }
40 }
41
42 #[must_use]
48 pub fn fingerprint(&self) -> String {
49 let mut fingerprint =
50 update_fnv_1a_fingerprint(FNV_1A_OFFSET_BASIS, PERSONALITY_FINGERPRINT_DOMAIN);
51 for component in [&self.id, &self.prompt] {
52 let byte_length = (component.len() as u128).to_le_bytes();
53 fingerprint = update_fnv_1a_fingerprint(fingerprint, &byte_length);
54 fingerprint = update_fnv_1a_fingerprint(fingerprint, component.as_bytes());
55 }
56
57 format!("{fingerprint:016x}")
58 }
59}
60
61fn update_fnv_1a_fingerprint(mut fingerprint: u64, bytes: &[u8]) -> u64 {
63 for byte in bytes {
64 fingerprint ^= u64::from(*byte);
65 fingerprint = fingerprint.wrapping_mul(FNV_1A_PRIME);
66 }
67
68 fingerprint
69}
70
71#[derive(Clone, Debug, Eq, PartialEq)]
73pub struct PersonalitySummary {
74 pub description: String,
76 pub id: String,
78 pub name: String,
80}
81
82#[derive(Default)]
84struct AgentFrontmatter {
85 description: Option<String>,
86 enabled: Option<bool>,
87 id: Option<String>,
88 name: Option<String>,
89}
90
91#[derive(Clone, Debug, Eq, PartialEq, thiserror::Error)]
93pub enum PersonalityParseError {
94 #[error("missing or incomplete frontmatter")]
96 MissingFrontmatter,
97 #[error("invalid frontmatter: {0}")]
99 InvalidFrontmatter(String),
100 #[error("missing required `{0}` frontmatter value")]
102 MissingField(&'static str),
103}
104
105pub fn parse_agent_definition(
115 directory_id: &str,
116 contents: &str,
117) -> Result<Option<Personality>, PersonalityParseError> {
118 let (frontmatter, body) = split_agent_definition(contents)?;
119 let Some(summary) = parse_agent_summary_parts(directory_id, frontmatter)? else {
120 return Ok(None);
121 };
122 let prompt = required_value(body, "prompt")?;
123
124 Ok(Some(Personality {
125 description: summary.description,
126 id: summary.id,
127 name: summary.name,
128 prompt: truncate_personality_prompt(prompt),
129 }))
130}
131
132pub fn parse_agent_summary(
142 directory_id: &str,
143 contents: &str,
144) -> Result<Option<PersonalitySummary>, PersonalityParseError> {
145 let (frontmatter, body) = split_agent_definition(contents)?;
146 let Some(summary) = parse_agent_summary_parts(directory_id, frontmatter)? else {
147 return Ok(None);
148 };
149 required_value(body, "prompt")?;
150
151 Ok(Some(summary))
152}
153
154fn parse_agent_summary_parts(
156 directory_id: &str,
157 frontmatter: &str,
158) -> Result<Option<PersonalitySummary>, PersonalityParseError> {
159 let frontmatter = parse_agent_frontmatter(frontmatter)?;
160 if frontmatter.enabled == Some(false) {
161 return Ok(None);
162 }
163
164 let id = required_value(frontmatter.id.as_deref().unwrap_or(directory_id), "id")?;
165 let name = required_value(frontmatter.name.as_deref().unwrap_or_default(), "name")?;
166 let description = required_value(
167 frontmatter.description.as_deref().unwrap_or_default(),
168 "description",
169 )?;
170 Ok(Some(PersonalitySummary {
171 description: description.to_string(),
172 id: id.to_string(),
173 name: name.to_string(),
174 }))
175}
176
177fn split_agent_definition(contents: &str) -> Result<(&str, &str), PersonalityParseError> {
179 let mut lines = contents.split_inclusive('\n');
180 let first_line = lines
181 .next()
182 .ok_or(PersonalityParseError::MissingFrontmatter)?;
183 if first_line.trim() != "---" {
184 return Err(PersonalityParseError::MissingFrontmatter);
185 }
186 let frontmatter_start = first_line.len();
187 let mut line_start = frontmatter_start;
188
189 for line in lines {
190 if line.trim() == "---" {
191 let body_start = line_start.saturating_add(line.len());
192
193 return Ok((
194 &contents[frontmatter_start..line_start],
195 &contents[body_start..],
196 ));
197 }
198 line_start = line_start.saturating_add(line.len());
199 }
200
201 Err(PersonalityParseError::MissingFrontmatter)
202}
203
204fn parse_agent_frontmatter(frontmatter: &str) -> Result<AgentFrontmatter, PersonalityParseError> {
206 let mut parsed = AgentFrontmatter::default();
207
208 for (line_index, line) in frontmatter.lines().enumerate() {
209 let line_number = line_index.saturating_add(1);
210 let line = line.trim();
211 if line.is_empty() {
212 continue;
213 }
214 let Some((key, value)) = line.split_once(':') else {
215 return Err(invalid_frontmatter_line(
216 line_number,
217 "expected `key: value`",
218 ));
219 };
220 let key = key.trim();
221 if key.is_empty() {
222 return Err(invalid_frontmatter_line(line_number, "key is empty"));
223 }
224 let value = parse_frontmatter_value(value.trim(), line_number)?;
225
226 match key {
227 "description" => {
228 set_frontmatter_string(&mut parsed.description, key, value, line_number)?;
229 }
230 "enabled" => {
231 if parsed.enabled.is_some() {
232 return Err(invalid_frontmatter_line(
233 line_number,
234 "duplicate `enabled` field",
235 ));
236 }
237 parsed.enabled = Some(match value {
238 "true" => true,
239 "false" => false,
240 _ => {
241 return Err(invalid_frontmatter_line(
242 line_number,
243 "`enabled` must be `true` or `false`",
244 ));
245 }
246 });
247 }
248 "id" => {
249 set_frontmatter_string(&mut parsed.id, key, value, line_number)?;
250 }
251 "name" => {
252 set_frontmatter_string(&mut parsed.name, key, value, line_number)?;
253 }
254 _ => {}
255 }
256 }
257
258 Ok(parsed)
259}
260
261fn invalid_frontmatter_line(line_number: usize, message: &str) -> PersonalityParseError {
263 PersonalityParseError::InvalidFrontmatter(format!("line {line_number}: {message}"))
264}
265
266fn parse_frontmatter_value(value: &str, line_number: usize) -> Result<&str, PersonalityParseError> {
268 let Some(quote) = value
269 .chars()
270 .next()
271 .filter(|quote| matches!(quote, '\'' | '"'))
272 else {
273 return Ok(value);
274 };
275 if value.len() < 2 || !value.ends_with(quote) {
276 return Err(invalid_frontmatter_line(
277 line_number,
278 "quoted value is not terminated",
279 ));
280 }
281
282 Ok(&value[quote.len_utf8()..value.len().saturating_sub(quote.len_utf8())])
283}
284
285fn set_frontmatter_string(
287 target: &mut Option<String>,
288 key: &str,
289 value: &str,
290 line_number: usize,
291) -> Result<(), PersonalityParseError> {
292 if target.is_some() {
293 return Err(invalid_frontmatter_line(
294 line_number,
295 &format!("duplicate `{key}` field"),
296 ));
297 }
298 *target = Some(value.to_string());
299
300 Ok(())
301}
302
303fn required_value<'a>(
305 value: &'a str,
306 field: &'static str,
307) -> Result<&'a str, PersonalityParseError> {
308 let value = value.trim();
309 if value.is_empty() {
310 return Err(PersonalityParseError::MissingField(field));
311 }
312
313 Ok(value)
314}
315
316fn truncate_personality_prompt(prompt: &str) -> String {
318 if prompt.len() <= PERSONALITY_PROMPT_MAX_BYTES {
319 return prompt.to_string();
320 }
321
322 let content_budget =
323 PERSONALITY_PROMPT_MAX_BYTES.saturating_sub(PERSONALITY_PROMPT_TRUNCATION_MARKER.len());
324 let mut boundary = content_budget.min(prompt.len());
325 while !prompt.is_char_boundary(boundary) {
326 boundary = boundary.saturating_sub(1);
327 }
328
329 format!(
330 "{}{}",
331 prompt[..boundary].trim_end(),
332 PERSONALITY_PROMPT_TRUNCATION_MARKER
333 )
334}
335
336#[cfg(test)]
337mod tests {
338 use super::*;
339
340 #[test]
341 fn test_parse_agent_definition_reads_enabled_profile() {
342 let definition = r#"---
344id: reviewer
345name: "Code Reviewer"
346description: 'Reviews code carefully'
347
348role: delegation-target
349enabled: true
350---
351
352Focus on correctness and security.
353"#;
354
355 let personality = parse_agent_definition("fallback", definition)
357 .expect("definition should parse")
358 .expect("definition should be enabled");
359
360 assert_eq!(
362 personality,
363 Personality {
364 description: "Reviews code carefully".to_string(),
365 id: "reviewer".to_string(),
366 name: "Code Reviewer".to_string(),
367 prompt: "Focus on correctness and security.".to_string(),
368 }
369 );
370 }
371
372 #[test]
373 fn test_parse_agent_summary_returns_metadata_without_prompt_body() {
374 let definition = "---\nid: reviewer\nname: Reviewer\ndescription: Reviews code\n---\nA \
376 very large prompt body.";
377
378 let summary = parse_agent_summary("fallback", definition)
380 .expect("definition should parse")
381 .expect("definition should be enabled");
382
383 assert_eq!(
385 summary,
386 PersonalitySummary {
387 description: "Reviews code".to_string(),
388 id: "reviewer".to_string(),
389 name: "Reviewer".to_string(),
390 }
391 );
392 }
393
394 #[test]
395 fn test_parse_agent_definition_uses_directory_id_when_id_is_missing() {
396 let definition =
398 "---\nname: Planner\ndescription: Plans work\nenabled: true\n---\nPlan first.";
399
400 let personality = parse_agent_definition("planner", definition)
402 .expect("definition should parse")
403 .expect("definition should be enabled");
404
405 assert_eq!(personality.id, "planner");
407 }
408
409 #[test]
410 fn test_parse_agent_definition_omits_disabled_profile() {
411 let definition =
413 "---\nname: Disabled\ndescription: Hidden\nenabled: false\n---\nDo not load.";
414
415 let personality =
417 parse_agent_definition("disabled", definition).expect("definition should parse");
418
419 assert_eq!(personality, None);
421 }
422
423 #[test]
424 fn test_parse_agent_definition_rejects_malformed_frontmatter() {
425 let definition = "---\nname Reviewer\n---\nReview code.";
427
428 let error = parse_agent_definition("reviewer", definition)
430 .expect_err("malformed frontmatter should fail");
431
432 assert!(matches!(
434 error,
435 PersonalityParseError::InvalidFrontmatter(_)
436 ));
437 }
438
439 #[test]
440 fn test_parse_agent_definition_rejects_invalid_enabled_value() {
441 let definition =
443 "---\nname: Reviewer\ndescription: Reviews code\nenabled: yes\n---\nReview code.";
444
445 let error = parse_agent_definition("reviewer", definition)
447 .expect_err("invalid enabled value should fail");
448
449 assert!(matches!(
451 error,
452 PersonalityParseError::InvalidFrontmatter(_)
453 ));
454 }
455
456 #[test]
457 fn test_parse_agent_definition_supports_quoted_values_and_crlf() {
458 let definition = "---\r\nname: 'Strict Reviewer'\r\ndescription: \"Reviews code: \
460 carefully\"\r\nenabled: true\r\n---\r\nReview carefully.\r\n";
461
462 let personality = parse_agent_definition("reviewer", definition)
464 .expect("frontmatter definition should parse")
465 .expect("definition should be enabled");
466
467 assert_eq!(personality.name, "Strict Reviewer");
469 assert_eq!(personality.description, "Reviews code: carefully");
470 assert_eq!(personality.prompt, "Review carefully.");
471 }
472
473 #[test]
474 fn test_parse_agent_definition_rejects_invalid_simple_frontmatter_fields() {
475 let duplicate = "---\nname: Reviewer\nname: Other\ndescription: Reviews code\n---\nReview.";
477 let duplicate_enabled = "---\nname: Reviewer\ndescription: Reviews code\nenabled: \
478 true\nenabled: false\n---\nReview.";
479 let empty_key = "---\n: value\nname: Reviewer\ndescription: Reviews code\n---\nReview.";
480 let unterminated = "---\nname: \"Reviewer\ndescription: Reviews code\n---\nReview.";
481
482 let duplicate_error = parse_agent_definition("reviewer", duplicate)
484 .expect_err("duplicate supported field should fail");
485 let duplicate_enabled_error = parse_agent_definition("reviewer", duplicate_enabled)
486 .expect_err("duplicate enabled field should fail");
487 let empty_key_error =
488 parse_agent_definition("reviewer", empty_key).expect_err("empty key should fail");
489 let unterminated_error = parse_agent_definition("reviewer", unterminated)
490 .expect_err("unterminated quoted value should fail");
491
492 assert!(matches!(
494 duplicate_error,
495 PersonalityParseError::InvalidFrontmatter(_)
496 ));
497 assert!(matches!(
498 duplicate_enabled_error,
499 PersonalityParseError::InvalidFrontmatter(_)
500 ));
501 assert!(matches!(
502 empty_key_error,
503 PersonalityParseError::InvalidFrontmatter(_)
504 ));
505 assert!(matches!(
506 unterminated_error,
507 PersonalityParseError::InvalidFrontmatter(_)
508 ));
509 }
510
511 #[test]
512 fn test_parse_agent_definition_rejects_missing_frontmatter_delimiter() {
513 let missing_open = "name: Reviewer\n---\nReview.";
515 let missing_close = "---\nname: Reviewer\nReview.";
516
517 let missing_open_error = parse_agent_definition("reviewer", missing_open)
519 .expect_err("opening delimiter should be required");
520 let missing_close_error = parse_agent_definition("reviewer", missing_close)
521 .expect_err("closing delimiter should be required");
522
523 assert_eq!(
525 missing_open_error,
526 PersonalityParseError::MissingFrontmatter
527 );
528 assert_eq!(
529 missing_close_error,
530 PersonalityParseError::MissingFrontmatter
531 );
532 }
533
534 #[test]
535 fn test_parse_agent_definition_requires_description_and_prompt() {
536 let missing_description = "---\nname: Reviewer\n---\nReview code.";
538 let missing_prompt = "---\nname: Reviewer\ndescription: Reviews code\n---\n";
539
540 let description_error = parse_agent_definition("reviewer", missing_description)
542 .expect_err("missing description should fail");
543 let prompt_error = parse_agent_definition("reviewer", missing_prompt)
544 .expect_err("missing prompt should fail");
545 let summary_prompt_error = parse_agent_summary("reviewer", missing_prompt)
546 .expect_err("summary should require a prompt");
547
548 assert_eq!(
550 description_error,
551 PersonalityParseError::MissingField("description")
552 );
553 assert_eq!(prompt_error, PersonalityParseError::MissingField("prompt"));
554 assert_eq!(
555 summary_prompt_error,
556 PersonalityParseError::MissingField("prompt")
557 );
558 }
559
560 #[test]
561 fn test_parse_agent_definition_truncates_large_prompt_at_utf8_boundary() {
562 let prompt = "é".repeat(PERSONALITY_PROMPT_MAX_BYTES);
564 let definition = format!("---\nname: Large\ndescription: Large prompt\n---\n{prompt}");
565
566 let personality = parse_agent_definition("large", &definition)
568 .expect("definition should parse")
569 .expect("definition should be enabled");
570
571 assert!(personality.prompt.len() <= PERSONALITY_PROMPT_MAX_BYTES);
573 assert!(
574 personality
575 .prompt
576 .ends_with(PERSONALITY_PROMPT_TRUNCATION_MARKER)
577 );
578 assert!(
579 personality
580 .prompt
581 .is_char_boundary(personality.prompt.len())
582 );
583 }
584
585 #[test]
586 fn test_personality_fingerprint_changes_with_id_or_prompt() {
587 let personality = Personality {
589 description: "Reviews code".to_string(),
590 id: "reviewer".to_string(),
591 name: "Reviewer".to_string(),
592 prompt: "Review carefully.".to_string(),
593 };
594 let mut changed_id = personality.clone();
595 changed_id.id = "security-reviewer".to_string();
596 let mut changed_prompt = personality.clone();
597 changed_prompt.prompt = "Review security carefully.".to_string();
598
599 let fingerprint = personality.fingerprint();
601
602 assert_eq!(fingerprint, "dad9785239f763e7");
604 assert_ne!(fingerprint, changed_id.fingerprint());
605 assert_ne!(fingerprint, changed_prompt.fingerprint());
606 }
607
608 #[test]
609 fn test_personality_fingerprint_separates_id_and_prompt_components() {
610 let first = Personality {
612 description: String::new(),
613 id: "ab".to_string(),
614 name: String::new(),
615 prompt: "c".to_string(),
616 };
617 let second = Personality {
618 description: String::new(),
619 id: "a".to_string(),
620 name: String::new(),
621 prompt: "bc".to_string(),
622 };
623
624 let first_fingerprint = first.fingerprint();
626 let second_fingerprint = second.fingerprint();
627
628 assert_ne!(first_fingerprint, second_fingerprint);
630 }
631}