use std::sync::Arc;
use futures_core::Stream;
use crate::llm::{
input_function_call_output, input_message, ChatGptClient, LlmError, ResponseStreamEvent,
ToolDefinition, Usage,
};
use crate::pob_parser::{PobParser, PobQuery};
const MAX_TOOL_ROUNDS: usize = 10;
const SYSTEM_PROMPT: &str = "\
You are a Path of Exile 2 build analysis assistant. The user has uploaded \
their Path of Building export.\n\
\n\
You have tools to inspect the build data. Use them to answer the user's \
questions accurately — do NOT guess at numbers.\n\
\n\
Start by calling get_build_stats to get an overview of the build's offense, \
defense, and resources. Then use get_skill_list or get_config if needed \
to answer the user's specific question.\n\
\n\
Use get_equipped_items to see all gear across every slot in one call — names, \
bases, rarity, and mod lines for filled slots, plus empty slot markers and \
socketed jewels. Use this for broad gear questions before diving into specifics.\n\
\n\
Use get_item to inspect a specific equipment slot when the user asks about \
their gear, an item's mods, or how a particular slot could be upgraded. \
Do not call get_item unless the question is about specific equipment.\n\
\n\
Use analyze_gear_mods for deep mod analysis on a specific gear slot. Unlike \
get_item (which just shows mod text), analyze_gear_mods shows each mod's \
tier, roll quality, affix name, current vs max range, and whether upgrades \
are available at the item's level. Use this when the user asks about mod \
tiers, roll quality, crafting upgrades, or \"how good are my rolls\". \
Not applicable to unique items, flasks, or charms.\n\
\n\
Use get_passive_tree when the user asks about their passive tree, allocated \
nodes, keystones, notables, ascendancy choices, masteries, or jewel sockets. \
It returns all allocated nodes categorized by type.\n\
\n\
Use get_jewel to inspect a jewel socketed in a passive tree socket. First call \
get_passive_tree to get the jewel_sockets list with node IDs, then call \
get_jewel with the node_id to see the jewel's name, base, rarity, and mods.\n\
\n\
Use query_passive_stats to find how much of a specific stat comes from allocated \
passives and what's available nearby on the tree. Provide a stat pattern like \
\"fire damage\" or \"maximum life\". Optionally set radius (default 3) to control \
how far to search from current allocation.\n\
\n\
Use get_unallocated_ascendancy to see which ascendancy nodes the character has \
allocated and which are still available. Returns both primary and secondary \
ascendancy nodes with node names, types, and stats. Use this when recommending \
ascendancy choices or when the user asks what ascendancy nodes to take next.\n\
\n\
Be specific and reference actual numbers from the build data when relevant. \
If the data doesn't contain enough information to answer, say so.\n\
\n\
Path of Exile 2 differences from Path of Exile 1 — do NOT confuse these:\n\
- There are NO utility flasks. Players have 2 flask slots (life/mana style only).\n\
- Charms (3 slots) provide passive bonuses and trigger effects — they replace \
much of what utility flasks did in PoE1.\n\
- Spirit is a resource that reserves for persistent buffs, auras, and minions.\n\
- Gear does NOT have gem sockets. Skill gems are equipped independently in \
dedicated active-gem slots, each with support sockets.\n\
- Rune sockets on gear provide bonus stats (via socketed runes).\n\
- Do NOT reference PoE1-specific unique items, support gems, or league mechanics.\n\
- When recommending items, gems, or tree nodes, verify they exist using the \
available tools rather than relying on memory.\n\
\n\
Use search_gems to look up skill gems in the PoE2 database. You can search by \
name, filter by type (active or support), and filter by tags (projectile, fire, \
area, duration, etc.). Always use this tool instead of guessing gem names or tags \
from memory — PoE2 gems are different from PoE1.\n\
\n\
Use search_uniques to look up unique items in the PoE2 database. You can search by \
name or mod text, filter by equipment slot, and filter by level range. Always use this \
tool instead of guessing unique item names or stats from memory.\n\
\n\
Use list_charms to see all available charm bases in PoE2. Charms auto-activate when a \
condition is met (e.g. becoming Frozen) and provide a temporary buff. This returns all \
13 charm bases — no parameters needed. Use this when the user asks about charm options \
or what charms exist.\n\
\n\
Use search_runes to look up runes and soul cores in the PoE2 database. Runes give \
different bonuses depending on the equipment slot they're socketed into. You can search \
by name or stat text, and filter by equipment slot. Always use this tool instead of \
guessing rune names or effects from memory.\n\
\n\
**create_item** — Creates a custom item in PoB text format, equips it in a slot, \
and returns the stat impact. The build is automatically saved as a new snapshot — no \
call to `update_build` is needed. Use this when the user asks to theorycraft gear \
(\"what if I had a bow with X?\", \"show me the impact of capping resistances\", etc.). \
Base type names must match PoE2 exactly (check `search_uniques` for unique bases, use \
your knowledge for rare/magic bases). Invalid base types return an error with guidance. \
Mods that don't match PoE2 data are accepted but may have no stat effect — prefer known \
mod line text. If replacing an existing item, call `get_item` first so you can tell the \
user what was replaced.";
#[derive(Debug, Clone)]
pub struct ChatMessage {
pub role: String,
pub content: String,
}
#[non_exhaustive]
pub enum AgentEvent {
ToolCall { name: String },
ToolResult { name: String, size_bytes: usize },
Token(String),
Usage(Usage),
BuildMutation { xml: String, label: String },
}
pub struct ToolAgent {
llm: ChatGptClient,
parser: Arc<PobParser>,
}
impl ToolAgent {
pub fn new(llm: ChatGptClient, parser: Arc<PobParser>) -> Self {
Self { llm, parser }
}
pub fn respond(
&self,
build_xml: &[u8],
message: &str,
history: Vec<ChatMessage>,
) -> impl Stream<Item = Result<AgentEvent, LlmError>> + Send {
let llm = self.llm.clone();
let parser = Arc::clone(&self.parser);
let build_xml = build_xml.to_vec();
let message = message.to_owned();
async_stream::try_stream! {
let tools = tool_definitions();
let mut input: Vec<serde_json::Value> = Vec::new();
for msg in history {
match msg.role.as_str() {
"user" | "assistant" => {
input.push(input_message(&msg.role, &msg.content));
}
_ => {}
}
}
input.push(input_message("user", &message));
let mut pending_mutation: Option<(String, String)> = None;
let mut previous_response_id: Option<String> = None;
let mut cumulative_usage = Usage::default();
for _ in 0..MAX_TOOL_ROUNDS {
let (call_input, call_instructions, call_tools) = if previous_response_id.is_some() {
(&input[..], None, Some(&tools[..]))
} else {
(&input[..], Some(SYSTEM_PROMPT), Some(&tools[..]))
};
let stream = llm.create_response_stream(
call_input,
call_instructions,
call_tools,
previous_response_id.as_deref(),
);
tokio::pin!(stream);
let mut function_calls = Vec::new();
while let Some(event) = futures_lite::StreamExt::next(&mut stream).await {
match event? {
ResponseStreamEvent::TextDelta(t) => {
yield AgentEvent::Token(t);
}
ResponseStreamEvent::FunctionCall(fc) => {
function_calls.push(fc);
}
ResponseStreamEvent::ResponseCompleted { id, usage } => {
previous_response_id = Some(id);
if let Some(u) = usage { cumulative_usage += u; }
}
}
}
if function_calls.is_empty() {
tracing::info!(
input_tokens = cumulative_usage.input_tokens,
output_tokens = cumulative_usage.output_tokens,
cached_tokens = cumulative_usage.cached_tokens(),
total_tokens = cumulative_usage.total_tokens,
"agent response complete"
);
yield AgentEvent::Usage(cumulative_usage);
if let Some((xml, label)) = pending_mutation {
yield AgentEvent::BuildMutation { xml, label };
}
return;
}
for fc in &function_calls {
yield AgentEvent::ToolCall { name: fc.name.clone() };
}
let mut tool_results = Vec::new();
for fc in &function_calls {
let result = execute_tool(&parser, &build_xml, &fc.name, &fc.arguments, &mut pending_mutation).await;
let content = match result {
Ok(val) => val.to_string(),
Err(e) => format!("{{\"error\": \"{e}\"}}"),
};
yield AgentEvent::ToolResult {
name: fc.name.clone(),
size_bytes: content.len(),
};
tool_results.push(input_function_call_output(&fc.call_id, &content));
}
input = tool_results;
}
tracing::info!(
input_tokens = cumulative_usage.input_tokens,
output_tokens = cumulative_usage.output_tokens,
cached_tokens = cumulative_usage.cached_tokens(),
total_tokens = cumulative_usage.total_tokens,
"agent response complete"
);
yield AgentEvent::Usage(cumulative_usage);
if let Some((xml, label)) = pending_mutation {
yield AgentEvent::BuildMutation { xml, label };
}
}
}
}
fn tool_definitions() -> Vec<ToolDefinition> {
vec![
ToolDefinition {
tool_type: "function".to_owned(),
name: "get_build_stats".to_owned(),
description: "Get extended build statistics including offense, defense, \
resources, speed, and charges. Returns ~40 fields grouped by category."
.to_owned(),
parameters: serde_json::json!({
"type": "object",
"properties": {},
"required": [],
"additionalProperties": false
}),
},
ToolDefinition {
tool_type: "function".to_owned(),
name: "get_skill_list".to_owned(),
description: "Get the list of skills with their DPS values, trigger info, \
and gem links (socket groups with gems, levels, and quality)."
.to_owned(),
parameters: serde_json::json!({
"type": "object",
"properties": {},
"required": [],
"additionalProperties": false
}),
},
ToolDefinition {
tool_type: "function".to_owned(),
name: "get_config".to_owned(),
description: "Get the build's configuration flags (enemy settings, \
charge generation, conditions, etc.)."
.to_owned(),
parameters: serde_json::json!({
"type": "object",
"properties": {},
"required": [],
"additionalProperties": false
}),
},
ToolDefinition {
tool_type: "function".to_owned(),
name: "get_item".to_owned(),
description: "Retrieve the item equipped in a specific gear slot, including \
its name, base type, rarity, and all mod lines (implicit, explicit, \
enchant, rune)."
.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 equipment slot to inspect"
}
},
"required": ["slot"],
"additionalProperties": false
}),
},
ToolDefinition {
tool_type: "function".to_owned(),
name: "analyze_gear_mods".to_owned(),
description: "Analyze an item's mods in depth: tier info (T1-T13), roll \
quality (0-100%), affix names, current vs max range, and whether \
better tiers are available at the item's level. Shows open prefix/suffix \
slots. Not applicable to unique items, flasks, or charms."
.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"
],
"description": "The equipment slot to analyze (excludes Charms and Flasks)"
}
},
"required": ["slot"],
"additionalProperties": false
}),
},
ToolDefinition {
tool_type: "function".to_owned(),
name: "get_equipped_items".to_owned(),
description: "Get all equipped items across every gear slot in one call. \
Returns compact info for each slot (name, base, rarity, mod lines), \
marks empty slots, and includes socketed jewels. Use this for broad \
gear questions before diving into specific items with get_item."
.to_owned(),
parameters: serde_json::json!({
"type": "object",
"properties": {},
"required": [],
"additionalProperties": false
}),
},
ToolDefinition {
tool_type: "function".to_owned(),
name: "get_jewel".to_owned(),
description: "Retrieve a jewel socketed in a passive tree socket, including \
its name, base type, rarity, and all mod lines. Use socket node IDs \
from get_passive_tree's jewel_sockets array."
.to_owned(),
parameters: serde_json::json!({
"type": "object",
"properties": {
"node_id": {
"type": "integer",
"description": "The passive tree socket node ID (from get_passive_tree jewel_sockets)"
}
},
"required": ["node_id"],
"additionalProperties": false
}),
},
ToolDefinition {
tool_type: "function".to_owned(),
name: "get_passive_tree".to_owned(),
description: "Get the allocated passive tree nodes, grouped by type: \
keystones, notables, ascendancy nodes, masteries, and jewel sockets. \
Also returns class, ascendancy, and total allocated node count."
.to_owned(),
parameters: serde_json::json!({
"type": "object",
"properties": {},
"required": [],
"additionalProperties": false
}),
},
ToolDefinition {
tool_type: "function".to_owned(),
name: "query_passive_stats".to_owned(),
description: "Query how much of one or more stats comes from allocated passive \
tree nodes, and how much more is available on nearby unallocated nodes. \
Accepts a list of stat patterns to batch multiple lookups in one call. \
Uses case-insensitive pattern matching on stat descriptions."
.to_owned(),
parameters: serde_json::json!({
"type": "object",
"properties": {
"stats": {
"type": "array",
"items": { "type": "string" },
"description": "Stat patterns to search for (e.g. [\"fire damage\", \"maximum life\", \"critical strike\"])"
},
"radius": {
"type": "integer",
"description": "How many hops from allocated nodes to search for nearby stats (default: 3)"
}
},
"required": ["stats"],
"additionalProperties": false
}),
},
ToolDefinition {
tool_type: "function".to_owned(),
name: "get_unallocated_ascendancy".to_owned(),
description: "Get the character's ascendancy nodes — both allocated and \
available — for primary and secondary ascendancies. Returns node names, \
types, stats, and points spent. Use this to recommend which ascendancy \
nodes to take next."
.to_owned(),
parameters: serde_json::json!({
"type": "object",
"properties": {},
"required": [],
"additionalProperties": false
}),
},
ToolDefinition {
tool_type: "function".to_owned(),
name: "get_skill_breakdown".to_owned(),
description: "Get a detailed DPS breakdown for a specific skill, including \
average hit, crit stats, speed, per-damage-type min/max, damage conversions, \
and ailment DPS. Recalculates with the skill as main skill. Use this when \
the user asks why a skill's DPS is low or wants to understand damage \
composition."
.to_owned(),
parameters: serde_json::json!({
"type": "object",
"properties": {
"skill": {
"type": "string",
"description": "The skill name to look up (case-insensitive substring match)"
}
},
"required": ["skill"],
"additionalProperties": false
}),
},
ToolDefinition {
tool_type: "function".to_owned(),
name: "search_gems".to_owned(),
description: "Search the PoE2 gem database by name, type, and/or tags. \
Returns matching gems with their type, tags, attribute requirements, \
and tier. Use this to find gems that match specific criteria instead \
of guessing from memory. Supports filtering by active vs support and \
by tags like projectile, fire, area, duration, etc."
.to_owned(),
parameters: serde_json::json!({
"type": "object",
"properties": {
"query": {
"type": "string",
"description": "Case-insensitive name substring (e.g. \"nova\", \"fire\")"
},
"type": {
"type": "string",
"enum": ["active", "support"],
"description": "Filter by active skills or support gems"
},
"tags": {
"type": "array",
"items": { "type": "string" },
"description": "Tags that must all match (e.g. [\"projectile\", \"fire\"])"
}
},
"additionalProperties": false
}),
},
ToolDefinition {
tool_type: "function".to_owned(),
name: "search_uniques".to_owned(),
description: "Search the PoE2 unique item database by name, slot, and/or \
level range. Returns matching uniques with their base type, slot, \
level requirement, and mod lines. Use this to find unique items that \
match specific criteria instead of guessing from memory."
.to_owned(),
parameters: serde_json::json!({
"type": "object",
"properties": {
"query": {
"type": "string",
"description": "Case-insensitive substring matched against item name and mod text"
},
"slot": {
"type": "string",
"description": "Filter by slot type (e.g. \"Ring\", \"Helmet\", \"Body Armour\", \"Shield\", \"Axe\", \"Bow\"). Case-insensitive substring match."
},
"min_level": {
"type": "integer",
"description": "Minimum level requirement"
},
"max_level": {
"type": "integer",
"description": "Maximum level requirement"
}
},
"additionalProperties": false
}),
},
ToolDefinition {
tool_type: "function".to_owned(),
name: "list_charms".to_owned(),
description: "List all PoE2 charm bases with their trigger condition, buff \
effect, duration, and charge info. Charms auto-activate when a condition \
is met (e.g. becoming Frozen). Returns all 13 charms — no parameters \
needed."
.to_owned(),
parameters: serde_json::json!({
"type": "object",
"properties": {},
"required": [],
"additionalProperties": false
}),
},
ToolDefinition {
tool_type: "function".to_owned(),
name: "search_runes".to_owned(),
description: "Search the PoE2 rune and soul core database by name or stat \
text. Runes give different bonuses depending on the equipment slot they're \
socketed into. Returns matching runes with per-slot stat lines including \
bonded effects."
.to_owned(),
parameters: serde_json::json!({
"type": "object",
"properties": {
"query": {
"type": "string",
"description": "Case-insensitive substring matched against rune name and stat text"
},
"slot": {
"type": "string",
"description": "Filter by equipment slot: weapon, caster, armour, helmet, body armour, boots, gloves, focus, sceptre"
}
},
"additionalProperties": false
}),
},
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 this to theorycraft gear — design any item \
in plain PoB text and instantly see how it changes DPS, EHP, and resistances.\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
}),
},
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_tool(
parser: &PobParser,
build_xml: &[u8],
tool_name: &str,
tool_args: &str,
pending_mutation: &mut Option<(String, String)>,
) -> Result<serde_json::Value, String> {
let query = match tool_name {
"get_build_stats" => PobQuery::BuildStats,
"get_skill_list" => PobQuery::SkillList,
"get_config" => PobQuery::Config,
"get_item" => {
let args: serde_json::Value =
serde_json::from_str(tool_args).map_err(|e| format!("invalid arguments: {e}"))?;
let slot = args["slot"]
.as_str()
.ok_or("missing required parameter: slot")?
.to_owned();
PobQuery::Item(slot)
}
"analyze_gear_mods" => {
let args: serde_json::Value =
serde_json::from_str(tool_args).map_err(|e| format!("invalid arguments: {e}"))?;
let slot = args["slot"]
.as_str()
.ok_or("missing required parameter: slot")?
.to_owned();
PobQuery::GearModAnalysis(slot)
}
"get_equipped_items" => PobQuery::EquippedItems,
"get_jewel" => {
let args: serde_json::Value =
serde_json::from_str(tool_args).map_err(|e| format!("invalid arguments: {e}"))?;
let node_id = args["node_id"]
.as_i64()
.ok_or("missing required parameter: node_id")?;
PobQuery::Jewel(node_id)
}
"get_passive_tree" => PobQuery::PassiveTree,
"query_passive_stats" => {
let args: serde_json::Value =
serde_json::from_str(tool_args).map_err(|e| format!("invalid arguments: {e}"))?;
let stats = if let Some(arr) = args["stats"].as_array() {
arr.iter()
.filter_map(|v| v.as_str().map(|s| s.to_owned()))
.collect::<Vec<_>>()
} else if let Some(s) = args["stat"].as_str() {
vec![s.to_owned()]
} else {
return Err("missing required parameter: stats".to_owned());
};
let radius = args["radius"].as_u64().unwrap_or(3) as u32;
PobQuery::PassiveStats { stats, radius }
}
"get_unallocated_ascendancy" => PobQuery::UnallocatedAscendancy,
"get_skill_breakdown" => {
let args: serde_json::Value =
serde_json::from_str(tool_args).map_err(|e| format!("invalid arguments: {e}"))?;
let skill = args["skill"]
.as_str()
.ok_or("missing required parameter: skill")?
.to_owned();
PobQuery::SkillBreakdown(skill)
}
"search_gems" => {
let args: serde_json::Value =
serde_json::from_str(tool_args).map_err(|e| format!("invalid arguments: {e}"))?;
let query = args["query"].as_str().map(|s| s.to_owned());
let gem_type = args["type"].as_str().map(|s| s.to_owned());
let tags = args["tags"]
.as_array()
.map(|arr| {
arr.iter()
.filter_map(|v| v.as_str().map(|s| s.to_owned()))
.collect::<Vec<_>>()
})
.unwrap_or_default();
if query.is_none() && tags.is_empty() {
return Err("at least one of 'query' or 'tags' must be provided".to_owned());
}
PobQuery::SearchGems {
query,
gem_type,
tags,
}
}
"search_uniques" => {
let args: serde_json::Value =
serde_json::from_str(tool_args).map_err(|e| format!("invalid arguments: {e}"))?;
let query = args["query"].as_str().map(|s| s.to_owned());
let slot = args["slot"].as_str().map(|s| s.to_owned());
let min_level = args["min_level"].as_u64().map(|n| n as u32);
let max_level = args["max_level"].as_u64().map(|n| n as u32);
if query.is_none() && slot.is_none() {
return Err("at least one of 'query' or 'slot' must be provided".to_owned());
}
PobQuery::SearchUniques {
query,
slot,
min_level,
max_level,
}
}
"list_charms" => PobQuery::ListCharms,
"search_runes" => {
let args: serde_json::Value =
serde_json::from_str(tool_args).map_err(|e| format!("invalid arguments: {e}"))?;
let query = args["query"].as_str().map(|s| s.to_owned());
let slot = args["slot"].as_str().map(|s| s.to_owned());
if query.is_none() && slot.is_none() {
return Err("at least one of 'query' or 'slot' must be provided".to_owned());
}
PobQuery::SearchRunes { query, slot }
}
"create_item" => {
let args: serde_json::Value =
serde_json::from_str(tool_args).map_err(|e| format!("invalid arguments: {e}"))?;
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 = parser
.query(
build_xml,
PobQuery::CreateItem {
slot: slot.clone(),
item_text,
},
)
.await
.map_err(|e| e.to_string())?;
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}");
*pending_mutation = Some((xml, label));
}
}
return Ok(result);
}
"update_build" => {
let args: serde_json::Value =
serde_json::from_str(tool_args).map_err(|e| format!("invalid arguments: {e}"))?;
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();
*pending_mutation = Some((xml, label));
return Ok(serde_json::json!({
"status": "ok",
"message": "Build mutation queued. It will be applied after your response."
}));
}
other => return Err(format!("unknown tool: {other}")),
};
parser
.query(build_xml, query)
.await
.map_err(|e| e.to_string())
}