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_item to inspect a specific equipment slot when the user asks about \
29their gear, an item's mods, or how a particular slot could be upgraded. \
30Do not call get_item unless the question is about specific equipment.\n\
31\n\
32Use get_passive_tree when the user asks about their passive tree, allocated \
33nodes, keystones, notables, ascendancy choices, masteries, or jewel sockets. \
34It returns all allocated nodes categorized by type.\n\
35\n\
36Use get_jewel to inspect a jewel socketed in a passive tree socket. First call \
37get_passive_tree to get the jewel_sockets list with node IDs, then call \
38get_jewel with the node_id to see the jewel's name, base, rarity, and mods.\n\
39\n\
40Be specific and reference actual numbers from the build data when relevant. \
41If the data doesn't contain enough information to answer, say so.";
42
43/// A single turn from a prior conversation. Text only — no tool calls.
44#[derive(Debug, Clone)]
45pub struct ChatMessage {
46    pub role: String,
47    pub content: String,
48}
49
50/// Events yielded by the agent during a response.
51pub enum AgentEvent {
52    /// The agent is calling a tool (yields tool name for progress indication).
53    ToolCall { name: String },
54    /// A token of the final streamed response.
55    Token(String),
56}
57
58/// Tool-calling build analysis agent.
59///
60/// Wraps an LLM client and a shared PoB parser. Each call to `respond`
61/// runs a ReAct loop: the LLM decides which tools to call, the agent
62/// executes them via the parser, and the results are fed back until
63/// the LLM produces a final answer.
64pub struct ToolAgent {
65    llm: ChatGptClient,
66    parser: Arc<PobParser>,
67}
68
69impl ToolAgent {
70    pub fn new(llm: ChatGptClient, parser: Arc<PobParser>) -> Self {
71        Self { llm, parser }
72    }
73
74    /// Stream a response to a user question about the given build.
75    ///
76    /// `build_xml` is the raw PoB XML export. The agent loads it into PoB
77    /// on each tool call so queries always reflect the full build.
78    pub fn respond(
79        &self,
80        build_xml: &[u8],
81        message: &str,
82        history: Vec<ChatMessage>,
83    ) -> impl Stream<Item = Result<AgentEvent, LlmError>> + Send {
84        let llm = self.llm.clone();
85        let parser = Arc::clone(&self.parser);
86        let build_xml = build_xml.to_vec();
87        let message = message.to_owned();
88
89        async_stream::try_stream! {
90            let tools = tool_definitions();
91            let mut messages = vec![Message::system(SYSTEM_PROMPT)];
92            for msg in history {
93                match msg.role.as_str() {
94                    "user" => messages.push(Message::user(&msg.content)),
95                    "assistant" => messages.push(Message::assistant(&msg.content)),
96                    _ => {}
97                }
98            }
99            messages.push(Message::user(message));
100
101            let mut tools_were_called = false;
102
103            for _ in 0..MAX_TOOL_ROUNDS {
104                let (assistant_msg, finish_reason) = llm
105                    .chat_with_tools(messages.clone(), Some(&tools))
106                    .await?;
107
108                let reason = finish_reason.as_deref().unwrap_or("stop");
109
110                if reason == "tool_calls" {
111                    if let Some(ref tool_calls) = assistant_msg.tool_calls {
112                        tools_were_called = true;
113
114                        // Yield progress events for each tool call
115                        for tc in tool_calls {
116                            yield AgentEvent::ToolCall {
117                                name: tc.function.name.clone(),
118                            };
119                        }
120
121                        // Append the assistant's tool_calls message
122                        messages.push(assistant_msg.clone());
123
124                        // Execute each tool and append results
125                        for tc in tool_calls {
126                            let result = execute_tool(&parser, &build_xml, &tc.function.name, &tc.function.arguments).await;
127                            let content = match result {
128                                Ok(val) => val.to_string(),
129                                Err(e) => format!("{{\"error\": \"{e}\"}}"),
130                            };
131                            messages.push(Message::tool_result(&tc.id, content));
132                        }
133
134                        continue;
135                    }
136                }
137
138                // finish_reason == "stop" (or anything else)
139                if !tools_were_called {
140                    // LLM answered directly without tools — yield its text
141                    if let Some(text) = assistant_msg.content {
142                        yield AgentEvent::Token(text);
143                    }
144                    return;
145                }
146
147                // Tools were called: discard non-streaming response, re-issue
148                // as streaming so the user sees tokens arrive progressively.
149                break;
150            }
151
152            // Final streaming pass with full conversation context, no tools.
153            let stream = llm.chat_stream(messages);
154            tokio::pin!(stream);
155            while let Some(token_result) = futures_lite::StreamExt::next(&mut stream).await {
156                yield AgentEvent::Token(token_result?);
157            }
158        }
159    }
160}
161
162/// Build the tool definitions for the agent.
163fn tool_definitions() -> Vec<ToolDefinition> {
164    vec![
165        ToolDefinition {
166            tool_type: "function".to_owned(),
167            function: FunctionDefinition {
168                name: "get_build_stats".to_owned(),
169                description: "Get extended build statistics including offense, defense, \
170                    resources, speed, and charges. Returns ~40 fields grouped by category."
171                    .to_owned(),
172                parameters: serde_json::json!({
173                    "type": "object",
174                    "properties": {},
175                    "required": [],
176                    "additionalProperties": false
177                }),
178            },
179        },
180        ToolDefinition {
181            tool_type: "function".to_owned(),
182            function: FunctionDefinition {
183                name: "get_skill_list".to_owned(),
184                description: "Get the list of skills with their DPS values, trigger info, \
185                    and gem links (socket groups with gems, levels, and quality)."
186                    .to_owned(),
187                parameters: serde_json::json!({
188                    "type": "object",
189                    "properties": {},
190                    "required": [],
191                    "additionalProperties": false
192                }),
193            },
194        },
195        ToolDefinition {
196            tool_type: "function".to_owned(),
197            function: FunctionDefinition {
198                name: "get_config".to_owned(),
199                description: "Get the build's configuration flags (enemy settings, \
200                    charge generation, conditions, etc.)."
201                    .to_owned(),
202                parameters: serde_json::json!({
203                    "type": "object",
204                    "properties": {},
205                    "required": [],
206                    "additionalProperties": false
207                }),
208            },
209        },
210        ToolDefinition {
211            tool_type: "function".to_owned(),
212            function: FunctionDefinition {
213                name: "get_item".to_owned(),
214                description: "Retrieve the item equipped in a specific gear slot, including \
215                    its name, base type, rarity, and all mod lines (implicit, explicit, \
216                    enchant, rune)."
217                    .to_owned(),
218                parameters: serde_json::json!({
219                    "type": "object",
220                    "properties": {
221                        "slot": {
222                            "type": "string",
223                            "enum": [
224                                "Weapon 1", "Weapon 2", "Helmet", "Body Armour",
225                                "Gloves", "Boots", "Amulet", "Ring 1", "Ring 2", "Ring 3",
226                                "Belt", "Charm 1", "Charm 2", "Charm 3",
227                                "Flask 1", "Flask 2"
228                            ],
229                            "description": "The equipment slot to inspect"
230                        }
231                    },
232                    "required": ["slot"],
233                    "additionalProperties": false
234                }),
235            },
236        },
237        ToolDefinition {
238            tool_type: "function".to_owned(),
239            function: FunctionDefinition {
240                name: "get_jewel".to_owned(),
241                description: "Retrieve a jewel socketed in a passive tree socket, including \
242                    its name, base type, rarity, and all mod lines. Use socket node IDs \
243                    from get_passive_tree's jewel_sockets array."
244                    .to_owned(),
245                parameters: serde_json::json!({
246                    "type": "object",
247                    "properties": {
248                        "node_id": {
249                            "type": "integer",
250                            "description": "The passive tree socket node ID (from get_passive_tree jewel_sockets)"
251                        }
252                    },
253                    "required": ["node_id"],
254                    "additionalProperties": false
255                }),
256            },
257        },
258        ToolDefinition {
259            tool_type: "function".to_owned(),
260            function: FunctionDefinition {
261                name: "get_passive_tree".to_owned(),
262                description: "Get the allocated passive tree nodes, grouped by type: \
263                    keystones, notables, ascendancy nodes, masteries, and jewel sockets. \
264                    Also returns class, ascendancy, and total allocated node count."
265                    .to_owned(),
266                parameters: serde_json::json!({
267                    "type": "object",
268                    "properties": {},
269                    "required": [],
270                    "additionalProperties": false
271                }),
272            },
273        },
274    ]
275}
276
277/// Execute a single tool call via the PoB parser.
278async fn execute_tool(
279    parser: &PobParser,
280    build_xml: &[u8],
281    tool_name: &str,
282    tool_args: &str,
283) -> Result<serde_json::Value, String> {
284    let query = match tool_name {
285        "get_build_stats" => PobQuery::BuildStats,
286        "get_skill_list" => PobQuery::SkillList,
287        "get_config" => PobQuery::Config,
288        "get_item" => {
289            let args: serde_json::Value =
290                serde_json::from_str(tool_args).map_err(|e| format!("invalid arguments: {e}"))?;
291            let slot = args["slot"]
292                .as_str()
293                .ok_or("missing required parameter: slot")?
294                .to_owned();
295            PobQuery::Item(slot)
296        }
297        "get_jewel" => {
298            let args: serde_json::Value =
299                serde_json::from_str(tool_args).map_err(|e| format!("invalid arguments: {e}"))?;
300            let node_id = args["node_id"]
301                .as_i64()
302                .ok_or("missing required parameter: node_id")?;
303            PobQuery::Jewel(node_id)
304        }
305        "get_passive_tree" => PobQuery::PassiveTree,
306        other => return Err(format!("unknown tool: {other}")),
307    };
308
309    parser
310        .query(build_xml, query)
311        .await
312        .map_err(|e| e.to_string())
313}