Skip to main content

poe2_agent/
agent.rs

1//! ReAct-style tool-calling agent for build analysis.
2//!
3//! Uses OpenAI function calling to query PoB data on demand
4//! rather than dumping everything into the system prompt upfront.
5
6use std::sync::Arc;
7
8use futures_core::Stream;
9
10use crate::llm::{
11    ChatGptClient, FunctionDefinition, LlmError, Message, ToolDefinition,
12};
13use crate::pob_parser::{PobParser, PobQuery};
14
15const MAX_TOOL_ROUNDS: usize = 10;
16
17const SYSTEM_PROMPT: &str = "\
18You are a Path of Exile 2 build analysis assistant. The user has uploaded \
19their Path of Building export.\n\
20\n\
21You have tools to inspect the build data. Use them to answer the user's \
22questions accurately — do NOT guess at numbers.\n\
23\n\
24Start by calling get_build_stats to get an overview of the build's offense, \
25defense, and resources. Then use get_skill_list or get_config if needed \
26to answer the user's specific question.\n\
27\n\
28Use get_empty_slots to quickly scan all equipment slots and see which ones \
29are empty and which have items equipped. This is useful for identifying \
30obvious upgrade opportunities — empty slots mean free power. Call this \
31before diving into individual items with get_item.\n\
32\n\
33Use get_item to inspect a specific equipment slot when the user asks about \
34their gear, an item's mods, or how a particular slot could be upgraded. \
35Do not call get_item unless the question is about specific equipment.\n\
36\n\
37Use get_passive_tree when the user asks about their passive tree, allocated \
38nodes, keystones, notables, ascendancy choices, masteries, or jewel sockets. \
39It returns all allocated nodes categorized by type.\n\
40\n\
41Use get_jewel to inspect a jewel socketed in a passive tree socket. First call \
42get_passive_tree to get the jewel_sockets list with node IDs, then call \
43get_jewel with the node_id to see the jewel's name, base, rarity, and mods.\n\
44\n\
45Use query_passive_stats to find how much of a specific stat comes from allocated \
46passives and what's available nearby on the tree. Provide a stat pattern like \
47\"fire damage\" or \"maximum life\". Optionally set radius (default 3) to control \
48how far to search from current allocation.\n\
49\n\
50Use get_unallocated_ascendancy to see which ascendancy nodes the character has \
51allocated and which are still available. Returns both primary and secondary \
52ascendancy nodes with node names, types, and stats. Use this when recommending \
53ascendancy choices or when the user asks what ascendancy nodes to take next.\n\
54\n\
55Be specific and reference actual numbers from the build data when relevant. \
56If the data doesn't contain enough information to answer, say so.\n\
57\n\
58Path of Exile 2 differences from Path of Exile 1 — do NOT confuse these:\n\
59- There are NO utility flasks. Players have 2 flask slots (life/mana style only).\n\
60- Charms (3 slots) provide passive bonuses and trigger effects — they replace \
61much of what utility flasks did in PoE1.\n\
62- Spirit is a resource that reserves for persistent buffs, auras, and minions.\n\
63- Gear does NOT have gem sockets. Skill gems are equipped independently in \
64dedicated active-gem slots, each with support sockets.\n\
65- Rune sockets on gear provide bonus stats (via socketed runes).\n\
66- Do NOT reference PoE1-specific unique items, support gems, or league mechanics.\n\
67- When recommending items, gems, or tree nodes, verify they exist using the \
68available tools rather than relying on memory.";
69
70/// A single turn from a prior conversation. Text only — no tool calls.
71#[derive(Debug, Clone)]
72pub struct ChatMessage {
73    pub role: String,
74    pub content: String,
75}
76
77/// Events yielded by the agent during a response.
78pub enum AgentEvent {
79    /// The agent is calling a tool (yields tool name for progress indication).
80    ToolCall { name: String },
81    /// A token of the final streamed response.
82    Token(String),
83}
84
85/// Tool-calling build analysis agent.
86///
87/// Wraps an LLM client and a shared PoB parser. Each call to `respond`
88/// runs a ReAct loop: the LLM decides which tools to call, the agent
89/// executes them via the parser, and the results are fed back until
90/// the LLM produces a final answer.
91pub struct ToolAgent {
92    llm: ChatGptClient,
93    parser: Arc<PobParser>,
94}
95
96impl ToolAgent {
97    pub fn new(llm: ChatGptClient, parser: Arc<PobParser>) -> Self {
98        Self { llm, parser }
99    }
100
101    /// Stream a response to a user question about the given build.
102    ///
103    /// `build_xml` is the raw PoB XML export. The agent loads it into PoB
104    /// on each tool call so queries always reflect the full build.
105    pub fn respond(
106        &self,
107        build_xml: &[u8],
108        message: &str,
109        history: Vec<ChatMessage>,
110    ) -> impl Stream<Item = Result<AgentEvent, LlmError>> + Send {
111        let llm = self.llm.clone();
112        let parser = Arc::clone(&self.parser);
113        let build_xml = build_xml.to_vec();
114        let message = message.to_owned();
115
116        async_stream::try_stream! {
117            let tools = tool_definitions();
118            let mut messages = vec![Message::system(SYSTEM_PROMPT)];
119            for msg in history {
120                match msg.role.as_str() {
121                    "user" => messages.push(Message::user(&msg.content)),
122                    "assistant" => messages.push(Message::assistant(&msg.content)),
123                    _ => {}
124                }
125            }
126            messages.push(Message::user(message));
127
128            // Tool-calling loop: let the LLM call tools until it has
129            // enough data, then break to stream the final answer.
130            let mut tools_were_called = false;
131
132            for _ in 0..MAX_TOOL_ROUNDS {
133                let (assistant_msg, finish_reason) = llm
134                    .chat_with_tools(messages.clone(), Some(&tools))
135                    .await?;
136
137                let reason = finish_reason.as_deref().unwrap_or("stop");
138
139                if reason != "tool_calls" {
140                    if !tools_were_called {
141                        // LLM answered directly without tools — yield its text.
142                        if let Some(text) = assistant_msg.content {
143                            yield AgentEvent::Token(text);
144                        }
145                        return;
146                    }
147                    // Tools were used in a prior round; break to stream.
148                    break;
149                }
150
151                if let Some(ref tool_calls) = assistant_msg.tool_calls {
152                    tools_were_called = true;
153
154                    for tc in tool_calls {
155                        yield AgentEvent::ToolCall {
156                            name: tc.function.name.clone(),
157                        };
158                    }
159
160                    messages.push(assistant_msg.clone());
161
162                    for tc in tool_calls {
163                        let result = execute_tool(&parser, &build_xml, &tc.function.name, &tc.function.arguments).await;
164                        let content = match result {
165                            Ok(val) => val.to_string(),
166                            Err(e) => format!("{{\"error\": \"{e}\"}}"),
167                        };
168                        messages.push(Message::tool_result(&tc.id, content));
169                    }
170                }
171            }
172
173            // Stream the final answer so tokens arrive progressively.
174            let stream = llm.chat_stream(messages);
175            tokio::pin!(stream);
176            while let Some(token_result) = futures_lite::StreamExt::next(&mut stream).await {
177                yield AgentEvent::Token(token_result?);
178            }
179        }
180    }
181}
182
183/// Build the tool definitions for the agent.
184fn tool_definitions() -> Vec<ToolDefinition> {
185    vec![
186        ToolDefinition {
187            tool_type: "function".to_owned(),
188            function: FunctionDefinition {
189                name: "get_build_stats".to_owned(),
190                description: "Get extended build statistics including offense, defense, \
191                    resources, speed, and charges. Returns ~40 fields grouped by category."
192                    .to_owned(),
193                parameters: serde_json::json!({
194                    "type": "object",
195                    "properties": {},
196                    "required": [],
197                    "additionalProperties": false
198                }),
199            },
200        },
201        ToolDefinition {
202            tool_type: "function".to_owned(),
203            function: FunctionDefinition {
204                name: "get_skill_list".to_owned(),
205                description: "Get the list of skills with their DPS values, trigger info, \
206                    and gem links (socket groups with gems, levels, and quality)."
207                    .to_owned(),
208                parameters: serde_json::json!({
209                    "type": "object",
210                    "properties": {},
211                    "required": [],
212                    "additionalProperties": false
213                }),
214            },
215        },
216        ToolDefinition {
217            tool_type: "function".to_owned(),
218            function: FunctionDefinition {
219                name: "get_config".to_owned(),
220                description: "Get the build's configuration flags (enemy settings, \
221                    charge generation, conditions, etc.)."
222                    .to_owned(),
223                parameters: serde_json::json!({
224                    "type": "object",
225                    "properties": {},
226                    "required": [],
227                    "additionalProperties": false
228                }),
229            },
230        },
231        ToolDefinition {
232            tool_type: "function".to_owned(),
233            function: FunctionDefinition {
234                name: "get_item".to_owned(),
235                description: "Retrieve the item equipped in a specific gear slot, including \
236                    its name, base type, rarity, and all mod lines (implicit, explicit, \
237                    enchant, rune)."
238                    .to_owned(),
239                parameters: serde_json::json!({
240                    "type": "object",
241                    "properties": {
242                        "slot": {
243                            "type": "string",
244                            "enum": [
245                                "Weapon 1", "Weapon 2", "Helmet", "Body Armour",
246                                "Gloves", "Boots", "Amulet", "Ring 1", "Ring 2", "Ring 3",
247                                "Belt", "Charm 1", "Charm 2", "Charm 3",
248                                "Flask 1", "Flask 2"
249                            ],
250                            "description": "The equipment slot to inspect"
251                        }
252                    },
253                    "required": ["slot"],
254                    "additionalProperties": false
255                }),
256            },
257        },
258        ToolDefinition {
259            tool_type: "function".to_owned(),
260            function: FunctionDefinition {
261                name: "get_empty_slots".to_owned(),
262                description: "Scan all equipment slots and return which are empty and which \
263                    have items equipped. Returns item name and rarity for filled slots. \
264                    Useful for quickly identifying missing gear without calling get_item \
265                    for every slot."
266                    .to_owned(),
267                parameters: serde_json::json!({
268                    "type": "object",
269                    "properties": {},
270                    "required": [],
271                    "additionalProperties": false
272                }),
273            },
274        },
275        ToolDefinition {
276            tool_type: "function".to_owned(),
277            function: FunctionDefinition {
278                name: "get_jewel".to_owned(),
279                description: "Retrieve a jewel socketed in a passive tree socket, including \
280                    its name, base type, rarity, and all mod lines. Use socket node IDs \
281                    from get_passive_tree's jewel_sockets array."
282                    .to_owned(),
283                parameters: serde_json::json!({
284                    "type": "object",
285                    "properties": {
286                        "node_id": {
287                            "type": "integer",
288                            "description": "The passive tree socket node ID (from get_passive_tree jewel_sockets)"
289                        }
290                    },
291                    "required": ["node_id"],
292                    "additionalProperties": false
293                }),
294            },
295        },
296        ToolDefinition {
297            tool_type: "function".to_owned(),
298            function: FunctionDefinition {
299                name: "get_passive_tree".to_owned(),
300                description: "Get the allocated passive tree nodes, grouped by type: \
301                    keystones, notables, ascendancy nodes, masteries, and jewel sockets. \
302                    Also returns class, ascendancy, and total allocated node count."
303                    .to_owned(),
304                parameters: serde_json::json!({
305                    "type": "object",
306                    "properties": {},
307                    "required": [],
308                    "additionalProperties": false
309                }),
310            },
311        },
312        ToolDefinition {
313            tool_type: "function".to_owned(),
314            function: FunctionDefinition {
315                name: "query_passive_stats".to_owned(),
316                description: "Query how much of a specific stat comes from allocated passive \
317                    tree nodes, and how much more is available on nearby unallocated nodes. \
318                    Uses case-insensitive pattern matching on stat descriptions."
319                    .to_owned(),
320                parameters: serde_json::json!({
321                    "type": "object",
322                    "properties": {
323                        "stat": {
324                            "type": "string",
325                            "description": "Stat pattern to search for (e.g. \"fire damage\", \"maximum life\", \"critical strike\")"
326                        },
327                        "radius": {
328                            "type": "integer",
329                            "description": "How many hops from allocated nodes to search for nearby stats (default: 3)"
330                        }
331                    },
332                    "required": ["stat"],
333                    "additionalProperties": false
334                }),
335            },
336        },
337        ToolDefinition {
338            tool_type: "function".to_owned(),
339            function: FunctionDefinition {
340                name: "get_unallocated_ascendancy".to_owned(),
341                description: "Get the character's ascendancy nodes — both allocated and \
342                    available — for primary and secondary ascendancies. Returns node names, \
343                    types, stats, and points spent. Use this to recommend which ascendancy \
344                    nodes to take next."
345                    .to_owned(),
346                parameters: serde_json::json!({
347                    "type": "object",
348                    "properties": {},
349                    "required": [],
350                    "additionalProperties": false
351                }),
352            },
353        },
354    ]
355}
356
357/// Execute a single tool call via the PoB parser.
358async fn execute_tool(
359    parser: &PobParser,
360    build_xml: &[u8],
361    tool_name: &str,
362    tool_args: &str,
363) -> Result<serde_json::Value, String> {
364    let query = match tool_name {
365        "get_build_stats" => PobQuery::BuildStats,
366        "get_skill_list" => PobQuery::SkillList,
367        "get_config" => PobQuery::Config,
368        "get_item" => {
369            let args: serde_json::Value =
370                serde_json::from_str(tool_args).map_err(|e| format!("invalid arguments: {e}"))?;
371            let slot = args["slot"]
372                .as_str()
373                .ok_or("missing required parameter: slot")?
374                .to_owned();
375            PobQuery::Item(slot)
376        }
377        "get_empty_slots" => PobQuery::EmptySlots,
378        "get_jewel" => {
379            let args: serde_json::Value =
380                serde_json::from_str(tool_args).map_err(|e| format!("invalid arguments: {e}"))?;
381            let node_id = args["node_id"]
382                .as_i64()
383                .ok_or("missing required parameter: node_id")?;
384            PobQuery::Jewel(node_id)
385        }
386        "get_passive_tree" => PobQuery::PassiveTree,
387        "query_passive_stats" => {
388            let args: serde_json::Value =
389                serde_json::from_str(tool_args).map_err(|e| format!("invalid arguments: {e}"))?;
390            let stat = args["stat"]
391                .as_str()
392                .ok_or("missing required parameter: stat")?
393                .to_owned();
394            let radius = args["radius"].as_u64().unwrap_or(3) as u32;
395            PobQuery::PassiveStats { stat, radius }
396        }
397        "get_unallocated_ascendancy" => PobQuery::UnallocatedAscendancy,
398        other => return Err(format!("unknown tool: {other}")),
399    };
400
401    parser
402        .query(build_xml, query)
403        .await
404        .map_err(|e| e.to_string())
405}