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_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#[derive(Debug, Clone)]
45pub struct ChatMessage {
46 pub role: String,
47 pub content: String,
48}
49
50pub enum AgentEvent {
52 ToolCall { name: String },
54 Token(String),
56}
57
58pub 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 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 for tc in tool_calls {
116 yield AgentEvent::ToolCall {
117 name: tc.function.name.clone(),
118 };
119 }
120
121 messages.push(assistant_msg.clone());
123
124 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 if !tools_were_called {
140 if let Some(text) = assistant_msg.content {
142 yield AgentEvent::Token(text);
143 }
144 return;
145 }
146
147 break;
150 }
151
152 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
162fn 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
277async 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}