poe2-agent 0.5.0

AI agent for Path of Exile 2 build analysis
Documentation
//! Build mutation tools — create_item and update_build.

use async_trait::async_trait;

use crate::llm::ToolDefinition;
use crate::pob_parser::PobQuery;

use super::{parse_args, BuildMutation, Tool, ToolContext, ToolResult};

/// Register mutation tools.
pub fn register(tools: &mut Vec<Box<dyn Tool>>) {
    tools.push(Box::new(CreateItem));
    tools.push(Box::new(UpdateBuild));
}

struct CreateItem;

#[async_trait]
impl Tool for CreateItem {
    fn definition(&self) -> ToolDefinition {
        ToolDefinition {
            tool_type: "function".to_owned(),
            name: "create_item".to_owned(),
            description: "Create a custom item from PoB item text format, equip it in a build \
                slot, and return the stat impact. Use search_bases first to get valid base type \
                names, and search_mods to get valid mod text. The response includes matched_mods \
                (count of recognized mods) and unmatched_mods (list of mod lines that PoB didn't \
                recognize — these have NO stat effect). If unmatched_mods is non-empty, use \
                search_mods to find correct text and re-create.\n\n\
                Item text format (newline-separated):\n\
                ```\n\
                Rarity: RARE\n\
                Dragon Song          <- custom title (Rare/Unique only)\n\
                Expert Shortbow      <- base type (must match PoE2 base exactly)\n\
                Item Level: 86\n\
                Quality: 20\n\
                Implicits: 1\n\
                +15% to Critical Hit Chance   <- implicit mod (count set by Implicits: N)\n\
                Adds 80 to 120 Physical Damage\n\
                +25% to Critical Hit Multiplier\n\
                35% increased Attack Speed\n\
                ```\n\n\
                Rarity values: NORMAL, MAGIC, RARE, UNIQUE\n\
                Normal/Magic: one name line (base type). Rare/Unique: two lines (title + base).\n\
                `Implicits: N` splits mod lines — first N are implicit, rest are explicit.\n\
                Use `{custom}` tag for custom mods: `{custom}+1 to Level of all Spell Skills`\n\
                Use `{range:0.5}` to set a mid-roll (0.0=min, 1.0=max): `{range:0.5}+50 to maximum Life`\n\n\
                If the slot already has an item, it is replaced. Check the slot first with \
                `get_item` if you need to know what was there."
                .to_owned(),
            parameters: serde_json::json!({
                "type": "object",
                "properties": {
                    "slot": {
                        "type": "string",
                        "enum": [
                            "Weapon 1", "Weapon 2", "Helmet", "Body Armour",
                            "Gloves", "Boots", "Amulet", "Ring 1", "Ring 2", "Ring 3",
                            "Belt", "Charm 1", "Charm 2", "Charm 3",
                            "Flask 1", "Flask 2"
                        ],
                        "description": "The slot to equip the new item in"
                    },
                    "item_text": {
                        "type": "string",
                        "description": "The item in PoB text format (newline-separated). See tool description for format."
                    }
                },
                "required": ["slot", "item_text"],
                "additionalProperties": false
            }),
        }
    }

    async fn execute(&self, ctx: &ToolContext<'_>, args: &str) -> Result<ToolResult, String> {
        let args = parse_args(args)?;
        let slot = args["slot"]
            .as_str()
            .ok_or("missing required parameter: slot")?
            .to_owned();
        let item_text = args["item_text"]
            .as_str()
            .ok_or("missing required parameter: item_text")?
            .to_owned();

        let mut result = ctx
            .parser
            .query(
                ctx.build_xml,
                PobQuery::CreateItem {
                    slot: slot.clone(),
                    item_text,
                },
            )
            .await
            .map_err(|e| e.to_string())?;

        // If successful, extract the XML and return it as a mutation.
        // Strip it from the LLM response — it's too large for context.
        if result.get("error").is_none() {
            if let Some(xml) = result
                .as_object_mut()
                .and_then(|m| m.remove("xml"))
                .and_then(|v| v.as_str().map(|s| s.to_owned()))
            {
                let label = format!("Equipped item in {slot}");
                return Ok(ToolResult {
                    response: result,
                    mutation: Some(BuildMutation { xml, label }),
                });
            }
        }

        Ok(ToolResult {
            response: result,
            mutation: None,
        })
    }
}

struct UpdateBuild;

#[async_trait]
impl Tool for UpdateBuild {
    fn definition(&self) -> ToolDefinition {
        ToolDefinition {
            tool_type: "function".to_owned(),
            name: "update_build".to_owned(),
            description: "Submit an updated build XML to be saved as a new snapshot. Call this \
                when you have made changes to the build XML and want to persist them. The XML \
                must be a complete, valid PathOfBuilding2 XML document."
                .to_owned(),
            parameters: serde_json::json!({
                "type": "object",
                "properties": {
                    "xml": {
                        "type": "string",
                        "description": "The complete updated PathOfBuilding2 XML document."
                    },
                    "label": {
                        "type": "string",
                        "description": "A short human-readable description of what changed, e.g. 'Swapped Fireball to Lightning Arrow'."
                    }
                },
                "required": ["xml", "label"],
                "additionalProperties": false
            }),
        }
    }

    async fn execute(&self, _ctx: &ToolContext<'_>, args: &str) -> Result<ToolResult, String> {
        let args = parse_args(args)?;
        let xml = args["xml"]
            .as_str()
            .ok_or_else(|| "missing xml parameter".to_owned())?
            .to_owned();
        let label = args["label"].as_str().unwrap_or("Updated build").to_owned();
        Ok(ToolResult {
            response: serde_json::json!({
                "status": "ok",
                "message": "Build mutation queued. It will be applied after your response."
            }),
            mutation: Some(BuildMutation { xml, label }),
        })
    }
}