1use 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#[derive(Debug, Clone)]
72pub struct ChatMessage {
73 pub role: String,
74 pub content: String,
75}
76
77pub enum AgentEvent {
79 ToolCall { name: String },
81 Token(String),
83}
84
85pub 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 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 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 if let Some(text) = assistant_msg.content {
143 yield AgentEvent::Token(text);
144 }
145 return;
146 }
147 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 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
183fn 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
357async 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}