lha 1.0.6

Long-Horizon Agent command-line package that installs the lha binary.
Documentation
use serde::Deserialize;
use serde::Serialize;

use crate::product::protocol::models::ContentItem;
use crate::product::protocol::models::TranscriptItem;

pub const USER_INSTRUCTIONS_OPEN_TAG_LEGACY: &str = "<user_instructions>";
pub const USER_INSTRUCTIONS_PREFIX: &str = "# AGENTS.md instructions for ";
const SKILL_OPEN_TAG: &str = "<skill>\n";
const BACKFILLED_SKILL_OPEN_TAG: &str = "<skill source=\"compact_backfill\">\n";
const SKILL_CLOSE_TAG: &str = "\n</skill>";
const SKILL_NAME_OPEN_TAG: &str = "<name>";
const SKILL_NAME_CLOSE_TAG: &str = "</name>\n";
const SKILL_PATH_OPEN_TAG: &str = "<path>";
const SKILL_PATH_CLOSE_TAG: &str = "</path>\n";

#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(rename = "user_instructions", rename_all = "snake_case")]
pub(crate) struct UserInstructions {
    pub directory: String,
    pub text: String,
}

impl UserInstructions {
    pub fn is_user_instructions(message: &[ContentItem]) -> bool {
        if let [ContentItem::InputText { text }] = message {
            text.starts_with(USER_INSTRUCTIONS_PREFIX)
                || text.starts_with(USER_INSTRUCTIONS_OPEN_TAG_LEGACY)
        } else {
            false
        }
    }
}

impl From<UserInstructions> for TranscriptItem {
    fn from(ui: UserInstructions) -> Self {
        TranscriptItem::Message {
            id: None,
            role: "user".to_string(),
            content: vec![ContentItem::InputText {
                text: format!(
                    "{USER_INSTRUCTIONS_PREFIX}{directory}\n\n<INSTRUCTIONS>\n{contents}\n</INSTRUCTIONS>",
                    directory = ui.directory,
                    contents = ui.text
                ),
            }],
            end_turn: None,
        }
    }
}

#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(rename = "skill_instructions", rename_all = "snake_case")]
pub(crate) struct SkillInstructions {
    pub name: String,
    pub path: String,
    pub contents: String,
}

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum SkillInstructionSource {
    Direct,
    CompactBackfill,
}

impl SkillInstructions {
    pub fn is_skill_instructions(message: &[ContentItem]) -> bool {
        Self::from_message_with_source(message).is_some()
    }

    #[allow(dead_code)]
    pub fn from_message(message: &[ContentItem]) -> Option<Self> {
        Self::from_message_with_source(message).map(|(skill, _)| skill)
    }

    pub fn from_message_with_source(
        message: &[ContentItem],
    ) -> Option<(Self, SkillInstructionSource)> {
        if let [ContentItem::InputText { text }] = message {
            Self::parse_with_source(text)
        } else {
            None
        }
    }

    #[allow(dead_code)]
    pub fn parse(text: &str) -> Option<Self> {
        Self::parse_with_source(text).map(|(skill, _)| skill)
    }

    pub fn parse_with_source(text: &str) -> Option<(Self, SkillInstructionSource)> {
        let (source, body) = parse_skill_open_tag(text)?;
        let body = body.strip_suffix(SKILL_CLOSE_TAG)?;
        let (name, body) = parse_skill_field(body, SKILL_NAME_OPEN_TAG, SKILL_NAME_CLOSE_TAG)?;
        let (path, contents) = parse_skill_field(body, SKILL_PATH_OPEN_TAG, SKILL_PATH_CLOSE_TAG)?;

        Some((
            Self {
                name,
                path,
                contents: contents.to_string(),
            },
            source,
        ))
    }

    pub fn into_backfilled_transcript_item(self) -> TranscriptItem {
        self.into_transcript_item_with_source(SkillInstructionSource::CompactBackfill)
    }

    fn into_transcript_item_with_source(self, source: SkillInstructionSource) -> TranscriptItem {
        let open_tag = match source {
            SkillInstructionSource::Direct => SKILL_OPEN_TAG,
            SkillInstructionSource::CompactBackfill => BACKFILLED_SKILL_OPEN_TAG,
        };

        TranscriptItem::Message {
            id: None,
            role: "user".to_string(),
            content: vec![ContentItem::InputText {
                text: format!(
                    "{open_tag}<name>{name}</name>\n<path>{path}</path>\n{contents}{SKILL_CLOSE_TAG}",
                    name = self.name,
                    path = self.path,
                    contents = self.contents
                ),
            }],
            end_turn: None,
        }
    }
}

impl From<SkillInstructions> for TranscriptItem {
    fn from(skill_instructions: SkillInstructions) -> Self {
        skill_instructions.into_transcript_item_with_source(SkillInstructionSource::Direct)
    }
}

fn parse_skill_open_tag(text: &str) -> Option<(SkillInstructionSource, &str)> {
    if let Some(body) = text.strip_prefix(SKILL_OPEN_TAG) {
        Some((SkillInstructionSource::Direct, body))
    } else {
        text.strip_prefix(BACKFILLED_SKILL_OPEN_TAG)
            .map(|body| (SkillInstructionSource::CompactBackfill, body))
    }
}

fn parse_skill_field<'a>(
    text: &'a str,
    open_tag: &str,
    close_tag: &str,
) -> Option<(String, &'a str)> {
    let text = text.strip_prefix(open_tag)?;
    let end = text.find(close_tag)?;
    let value = text[..end].to_string();
    let rest = &text[end + close_tag.len()..];
    Some((value, rest))
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::product::protocol::models::TranscriptItem;
    use pretty_assertions::assert_eq;

    #[test]
    fn test_user_instructions() {
        let user_instructions = UserInstructions {
            directory: "test_directory".to_string(),
            text: "test_text".to_string(),
        };
        let response_item: TranscriptItem = user_instructions.into();

        let TranscriptItem::Message { role, content, .. } = response_item else {
            panic!("expected TranscriptItem::Message");
        };

        assert_eq!(role, "user");

        let [ContentItem::InputText { text }] = content.as_slice() else {
            panic!("expected one InputText content item");
        };

        assert_eq!(
            text,
            "# AGENTS.md instructions for test_directory\n\n<INSTRUCTIONS>\ntest_text\n</INSTRUCTIONS>",
        );
    }

    #[test]
    fn test_is_user_instructions() {
        assert!(UserInstructions::is_user_instructions(
            &[ContentItem::InputText {
                text: "# AGENTS.md instructions for test_directory\n\n<INSTRUCTIONS>\ntest_text\n</INSTRUCTIONS>".to_string(),
            }]
        ));
        assert!(UserInstructions::is_user_instructions(&[
            ContentItem::InputText {
                text: "<user_instructions>test_text</user_instructions>".to_string(),
            }
        ]));
        assert!(!UserInstructions::is_user_instructions(&[
            ContentItem::InputText {
                text: "test_text".to_string(),
            }
        ]));
    }

    #[test]
    fn test_skill_instructions() {
        let skill_instructions = SkillInstructions {
            name: "demo-skill".to_string(),
            path: "skills/demo/SKILL.md".to_string(),
            contents: "body".to_string(),
        };
        let response_item: TranscriptItem = skill_instructions.into();

        let TranscriptItem::Message { role, content, .. } = response_item else {
            panic!("expected TranscriptItem::Message");
        };

        assert_eq!(role, "user");

        let [ContentItem::InputText { text }] = content.as_slice() else {
            panic!("expected one InputText content item");
        };

        assert_eq!(
            text,
            "<skill>\n<name>demo-skill</name>\n<path>skills/demo/SKILL.md</path>\nbody\n</skill>",
        );
    }

    #[test]
    fn test_is_skill_instructions() {
        assert!(SkillInstructions::is_skill_instructions(&[
            ContentItem::InputText {
                text: "<skill>\n<name>demo-skill</name>\n<path>skills/demo/SKILL.md</path>\nbody\n</skill>"
                    .to_string(),
            }
        ]));
        assert!(SkillInstructions::is_skill_instructions(&[
            ContentItem::InputText {
                text: "<skill source=\"compact_backfill\">\n<name>demo-skill</name>\n<path>skills/demo/SKILL.md</path>\nbody\n</skill>"
                    .to_string(),
            }
        ]));
        assert!(!SkillInstructions::is_skill_instructions(&[
            ContentItem::InputText {
                text: "regular text".to_string(),
            }
        ]));
    }

    #[test]
    fn test_parse_skill_instructions() {
        let parsed = SkillInstructions::parse_with_source(
            "<skill>\n<name>demo-skill</name>\n<path>skills/demo/SKILL.md</path>\nbody\n</skill>",
        );

        assert_eq!(
            parsed,
            Some((
                SkillInstructions {
                    name: "demo-skill".to_string(),
                    path: "skills/demo/SKILL.md".to_string(),
                    contents: "body".to_string(),
                },
                SkillInstructionSource::Direct,
            ))
        );
    }

    #[test]
    fn test_parse_skill_instructions_rejects_invalid_messages() {
        assert_eq!(
            SkillInstructions::parse("<skill>\n<name>demo-skill</name>\nbody\n</skill>"),
            None
        );
        assert_eq!(SkillInstructions::parse("regular text"), None);
        assert_eq!(
            SkillInstructions::parse(
                "<skill source=\"unknown\">\n<name>demo-skill</name>\n<path>skills/demo/SKILL.md</path>\nbody\n</skill>",
            ),
            None
        );
    }

    #[test]
    fn test_skill_instructions_round_trip() {
        let expected = SkillInstructions {
            name: "demo-skill".to_string(),
            path: "skills/demo/SKILL.md".to_string(),
            contents: "body\nwith more".to_string(),
        };
        let response_item: TranscriptItem = expected.clone().into();
        let TranscriptItem::Message { content, .. } = response_item else {
            panic!("expected TranscriptItem::Message");
        };

        let parsed = SkillInstructions::from_message(&content);

        assert_eq!(parsed, Some(expected));
    }

    #[test]
    fn test_parse_backfilled_skill_instructions() {
        let parsed = SkillInstructions::parse_with_source(
            "<skill source=\"compact_backfill\">\n<name>demo-skill</name>\n<path>skills/demo/SKILL.md</path>\nbody\n</skill>",
        );

        assert_eq!(
            parsed,
            Some((
                SkillInstructions {
                    name: "demo-skill".to_string(),
                    path: "skills/demo/SKILL.md".to_string(),
                    contents: "body".to_string(),
                },
                SkillInstructionSource::CompactBackfill,
            ))
        );
    }

    #[test]
    fn test_backfilled_skill_instructions_round_trip() {
        let expected = SkillInstructions {
            name: "demo-skill".to_string(),
            path: "skills/demo/SKILL.md".to_string(),
            contents: "body\nwith more".to_string(),
        };
        let response_item = expected.clone().into_backfilled_transcript_item();
        let TranscriptItem::Message { content, .. } = response_item else {
            panic!("expected TranscriptItem::Message");
        };

        let parsed = SkillInstructions::from_message_with_source(&content);

        assert_eq!(
            parsed,
            Some((expected, SkillInstructionSource::CompactBackfill))
        );
    }
}