tiny-agent 0.1.0

一个小而完整的 Rust LLM Agent 运行时:可中断、可恢复、可观测、可插拔的 agent loop / A small but complete LLM agent runtime in Rust — an interruptible, resumable, observable, pluggable agent loop.
Documentation
use crate::{
    providers::LLMRequest,
    shared::{Message, ToolDefinition},
    skills::SkillIndexEntry,
    state::AgentState,
};

#[derive(Debug, Default)]
pub(crate) struct PromptComposer;

pub(crate) struct PromptComposeInput {
    pub(crate) model: String,
    pub(crate) system_prompt: Option<String>,
    pub(crate) history: Vec<Message>,
    pub(crate) tools: Vec<ToolDefinition>,
    pub(crate) skills: Vec<SkillIndexEntry>,
    pub(crate) temperature: Option<f32>,
    pub(crate) max_tokens: Option<u32>,
}

impl PromptComposer {
    pub(crate) fn compose(&self, input: PromptComposeInput, _ctx: &AgentState) -> LLMRequest {
        LLMRequest {
            model: input.model,
            system: render_system(input.system_prompt, input.skills),
            messages: input.history,
            tools: input.tools,
            temperature: input.temperature,
            max_tokens: input.max_tokens,
        }
    }
}

fn render_system(system_prompt: Option<String>, skills: Vec<SkillIndexEntry>) -> Option<String> {
    let mut parts = Vec::new();
    if let Some(system_prompt) = system_prompt {
        if !system_prompt.trim().is_empty() {
            parts.push(system_prompt);
        }
    }
    if !skills.is_empty() {
        let mut skill_index = String::from(
            "Available skills are listed below. A skill is not active until you inspect its instructions. Use the readSkill tool to read only the skill instructions or skill resources that are relevant to the current task.\n\nAvailable skills:",
        );
        for skill in skills {
            skill_index.push_str("\n- ");
            skill_index.push_str(&skill.id);
            skill_index.push_str(" (");
            skill_index.push_str(&skill.name);
            skill_index.push_str("): ");
            skill_index.push_str(&skill.description);
        }
        parts.push(skill_index);
    }
    if parts.is_empty() {
        None
    } else {
        Some(parts.join("\n\n"))
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn compose_omits_system_without_skills() {
        let req = PromptComposer.compose(
            PromptComposeInput {
                model: "test".to_string(),
                system_prompt: None,
                history: Vec::new(),
                tools: Vec::new(),
                skills: Vec::new(),
                temperature: None,
                max_tokens: None,
            },
            &AgentState::new(Some("s".to_string())),
        );

        assert!(req.system.is_none());
        assert!(req.tools.is_empty());
    }

    #[test]
    fn compose_renders_skill_index_without_tool_catalog() {
        let req = PromptComposer.compose(
            PromptComposeInput {
                model: "test".to_string(),
                system_prompt: Some("You are careful.".to_string()),
                history: Vec::new(),
                tools: vec![ToolDefinition {
                    name: "bash".to_string(),
                    desc: "execute bash".to_string(),
                    arguments: serde_json::json!({}),
                }],
                skills: vec![SkillIndexEntry {
                    id: "docs".to_string(),
                    name: "Documents".to_string(),
                    description: "Work with documents".to_string(),
                }],
                temperature: Some(0.4),
                max_tokens: Some(1024),
            },
            &AgentState::new(Some("s".to_string())),
        );

        let system = req.system.unwrap();
        assert!(system.contains("docs"));
        assert!(system.contains("readSkill"));
        assert!(system.contains("You are careful."));
        assert!(!system.contains("execute bash"));
        assert_eq!(req.tools.len(), 1);
        assert_eq!(req.temperature, Some(0.4));
        assert_eq!(req.max_tokens, Some(1024));
    }
}