Skip to main content

ag_session/
personality.rs

1//! Workspace personality definitions parsed from `.agents/agents/*/agent.md`.
2
3/// Maximum UTF-8 byte length retained for one personality prompt.
4pub const PERSONALITY_PROMPT_MAX_BYTES: usize = 16 * 1024;
5
6/// FNV-1a domain separator for the stable personality fingerprint encoding.
7const PERSONALITY_FINGERPRINT_DOMAIN: &[u8] = b"agentty-personality-v1";
8
9/// FNV-1a 64-bit offset basis.
10const FNV_1A_OFFSET_BASIS: u64 = 0xcbf2_9ce4_8422_2325;
11
12/// FNV-1a 64-bit prime.
13const FNV_1A_PRIME: u64 = 0x0000_0100_0000_01b3;
14
15/// Marker appended when a personality prompt exceeds the supported budget.
16const PERSONALITY_PROMPT_TRUNCATION_MARKER: &str = "\n\n[Personality prompt truncated at 16 KiB.]";
17
18/// One enabled personality loaded from a workspace agent definition.
19#[derive(Clone, Debug, Eq, PartialEq)]
20pub struct Personality {
21    /// Short explanation shown beside the personality name.
22    pub description: String,
23    /// Stable identifier persisted on the owning session.
24    pub id: String,
25    /// Human-readable picker label.
26    pub name: String,
27    /// Behavioral preamble injected into agent turns.
28    pub prompt: String,
29}
30
31impl Personality {
32    /// Returns lightweight picker metadata without retaining the prompt body.
33    #[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    /// Returns a deterministic fingerprint for the selected ID and prompt.
43    ///
44    /// Fingerprints only detect changes; they are not used for cryptographic
45    /// verification. The versioned FNV-1a encoding is stable across processes,
46    /// platforms, and Rust toolchain upgrades.
47    #[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
61/// Updates one FNV-1a fingerprint with the supplied bytes.
62fn 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/// Lightweight personality metadata stored in prompt-composer state.
72#[derive(Clone, Debug, Eq, PartialEq)]
73pub struct PersonalitySummary {
74    /// Short explanation shown beside the personality name.
75    pub description: String,
76    /// Stable identifier persisted on the owning session.
77    pub id: String,
78    /// Human-readable picker label.
79    pub name: String,
80}
81
82/// Supported fields decoded from one `.agents` frontmatter block.
83#[derive(Default)]
84struct AgentFrontmatter {
85    description: Option<String>,
86    enabled: Option<bool>,
87    id: Option<String>,
88    name: Option<String>,
89}
90
91/// Error returned when one agent definition cannot be parsed safely.
92#[derive(Clone, Debug, Eq, PartialEq, thiserror::Error)]
93pub enum PersonalityParseError {
94    /// The definition does not contain a complete frontmatter block.
95    #[error("missing or incomplete frontmatter")]
96    MissingFrontmatter,
97    /// The simple `key: value` frontmatter is malformed.
98    #[error("invalid frontmatter: {0}")]
99    InvalidFrontmatter(String),
100    /// A required frontmatter value is absent or blank.
101    #[error("missing required `{0}` frontmatter value")]
102    MissingField(&'static str),
103}
104
105/// Parses one `.agents` agent definition.
106///
107/// `directory_id` is used when the optional frontmatter `id` is absent.
108/// Disabled definitions return `Ok(None)` so callers can omit them without
109/// treating intentional configuration as a parse failure.
110///
111/// # Errors
112/// Returns [`PersonalityParseError`] when frontmatter is malformed or a
113/// required name, description, directory fallback ID, or prompt is missing.
114pub 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
132/// Parses lightweight picker metadata from one `.agents` agent definition.
133///
134/// Callers may supply a body reduced to any non-empty placeholder because the
135/// returned value does not retain prompt text. Disabled definitions return
136/// `Ok(None)`.
137///
138/// # Errors
139/// Returns [`PersonalityParseError`] under the same validation rules as
140/// [`parse_agent_definition`].
141pub 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
154/// Validates frontmatter and builds lightweight picker metadata.
155fn 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
177/// Splits an agent definition into simple frontmatter and Markdown body.
178fn 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
204/// Parses the protocol's simple line-oriented `key: value` frontmatter.
205fn 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
261/// Builds one line-numbered frontmatter parsing error.
262fn invalid_frontmatter_line(line_number: usize, message: &str) -> PersonalityParseError {
263    PersonalityParseError::InvalidFrontmatter(format!("line {line_number}: {message}"))
264}
265
266/// Removes matching single or double quotes from one frontmatter value.
267fn 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
285/// Assigns one supported string field and rejects duplicates.
286fn 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
303/// Returns one non-empty required value.
304fn 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
316/// Truncates a prompt at a UTF-8 boundary while retaining the marker budget.
317fn 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        // Arrange
343        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        // Act
356        let personality = parse_agent_definition("fallback", definition)
357            .expect("definition should parse")
358            .expect("definition should be enabled");
359
360        // Assert
361        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        // Arrange
375        let definition = "---\nid: reviewer\nname: Reviewer\ndescription: Reviews code\n---\nA \
376                          very large prompt body.";
377
378        // Act
379        let summary = parse_agent_summary("fallback", definition)
380            .expect("definition should parse")
381            .expect("definition should be enabled");
382
383        // Assert
384        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        // Arrange
397        let definition =
398            "---\nname: Planner\ndescription: Plans work\nenabled: true\n---\nPlan first.";
399
400        // Act
401        let personality = parse_agent_definition("planner", definition)
402            .expect("definition should parse")
403            .expect("definition should be enabled");
404
405        // Assert
406        assert_eq!(personality.id, "planner");
407    }
408
409    #[test]
410    fn test_parse_agent_definition_omits_disabled_profile() {
411        // Arrange
412        let definition =
413            "---\nname: Disabled\ndescription: Hidden\nenabled: false\n---\nDo not load.";
414
415        // Act
416        let personality =
417            parse_agent_definition("disabled", definition).expect("definition should parse");
418
419        // Assert
420        assert_eq!(personality, None);
421    }
422
423    #[test]
424    fn test_parse_agent_definition_rejects_malformed_frontmatter() {
425        // Arrange
426        let definition = "---\nname Reviewer\n---\nReview code.";
427
428        // Act
429        let error = parse_agent_definition("reviewer", definition)
430            .expect_err("malformed frontmatter should fail");
431
432        // Assert
433        assert!(matches!(
434            error,
435            PersonalityParseError::InvalidFrontmatter(_)
436        ));
437    }
438
439    #[test]
440    fn test_parse_agent_definition_rejects_invalid_enabled_value() {
441        // Arrange
442        let definition =
443            "---\nname: Reviewer\ndescription: Reviews code\nenabled: yes\n---\nReview code.";
444
445        // Act
446        let error = parse_agent_definition("reviewer", definition)
447            .expect_err("invalid enabled value should fail");
448
449        // Assert
450        assert!(matches!(
451            error,
452            PersonalityParseError::InvalidFrontmatter(_)
453        ));
454    }
455
456    #[test]
457    fn test_parse_agent_definition_supports_quoted_values_and_crlf() {
458        // Arrange
459        let definition = "---\r\nname: 'Strict Reviewer'\r\ndescription: \"Reviews code: \
460                          carefully\"\r\nenabled: true\r\n---\r\nReview carefully.\r\n";
461
462        // Act
463        let personality = parse_agent_definition("reviewer", definition)
464            .expect("frontmatter definition should parse")
465            .expect("definition should be enabled");
466
467        // Assert
468        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        // Arrange
476        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        // Act
483        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
493        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        // Arrange
514        let missing_open = "name: Reviewer\n---\nReview.";
515        let missing_close = "---\nname: Reviewer\nReview.";
516
517        // Act
518        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
524        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        // Arrange
537        let missing_description = "---\nname: Reviewer\n---\nReview code.";
538        let missing_prompt = "---\nname: Reviewer\ndescription: Reviews code\n---\n";
539
540        // Act
541        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
549        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        // Arrange
563        let prompt = "é".repeat(PERSONALITY_PROMPT_MAX_BYTES);
564        let definition = format!("---\nname: Large\ndescription: Large prompt\n---\n{prompt}");
565
566        // Act
567        let personality = parse_agent_definition("large", &definition)
568            .expect("definition should parse")
569            .expect("definition should be enabled");
570
571        // Assert
572        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        // Arrange
588        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        // Act
600        let fingerprint = personality.fingerprint();
601
602        // Assert
603        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        // Arrange
611        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        // Act
625        let first_fingerprint = first.fingerprint();
626        let second_fingerprint = second.fingerprint();
627
628        // Assert
629        assert_ne!(first_fingerprint, second_fingerprint);
630    }
631}