Skip to main content

poe2_agent/
agent.rs

1//! ReAct-style tool-calling agent for build analysis.
2//!
3//! Uses the OpenAI Responses API for function calling to query PoB data
4//! on demand rather than dumping everything into the system prompt upfront.
5
6use std::sync::Arc;
7
8use futures_core::Stream;
9
10use crate::llm::{
11    input_function_call_output, input_message, ChatGptClient, LlmError, ResponseStreamEvent, Usage,
12};
13use crate::pob_parser::PobParser;
14use crate::tools::{BuildMutation, ToolContext, ToolRegistry};
15use crate::trace::{AgentTrace, TraceBuilder, TraceMessage};
16use crate::trade::TradeClient;
17
18const MAX_TOOL_ROUNDS: usize = 10;
19
20const SYSTEM_PROMPT: &str = "\
21You are a Path of Exile 2 build analysis assistant. The user has uploaded \
22their Path of Building export.\n\
23\n\
24You have tools to inspect the build data. Use them to answer the user's \
25questions accurately — do NOT guess at numbers.\n\
26\n\
27Start by calling get_build_stats to get an overview of the build's offense, \
28defense, and resources. Then use get_skill_list or get_config if needed \
29to answer the user's specific question.\n\
30\n\
31Use get_equipped_items to see all gear across every slot in one call — names, \
32bases, rarity, and mod lines for filled slots, plus empty slot markers and \
33socketed jewels. Use this for broad gear questions before diving into specifics.\n\
34\n\
35Use get_item to inspect a specific equipment slot when the user asks about \
36their gear, an item's mods, or how a particular slot could be upgraded. \
37Do not call get_item unless the question is about specific equipment.\n\
38\n\
39Use analyze_gear_mods for deep mod analysis on a specific gear slot. Unlike \
40get_item (which just shows mod text), analyze_gear_mods shows each mod's \
41tier, roll quality, affix name, current vs max range, and whether upgrades \
42are available at the item's level. Use this when the user asks about mod \
43tiers, roll quality, crafting upgrades, or \"how good are my rolls\". \
44Not applicable to unique items, flasks, or charms.\n\
45\n\
46Use get_passive_tree when the user asks about their passive tree, allocated \
47nodes, keystones, notables, ascendancy choices, masteries, or jewel sockets. \
48It returns all allocated nodes categorized by type.\n\
49\n\
50Use get_jewel to inspect a jewel socketed in a passive tree socket. First call \
51get_passive_tree to get the jewel_sockets list with node IDs, then call \
52get_jewel with the node_id to see the jewel's name, base, rarity, and mods.\n\
53\n\
54Use query_passive_stats to find how much of a specific stat comes from allocated \
55passives and what's available nearby on the tree. Provide a stat pattern like \
56\"fire damage\" or \"maximum life\". Optionally set radius (default 3) to control \
57how far to search from current allocation.\n\
58\n\
59Use get_unallocated_ascendancy to see which ascendancy nodes the character has \
60allocated and which are still available. Returns both primary and secondary \
61ascendancy nodes with node names, types, and stats. Use this when recommending \
62ascendancy choices or when the user asks what ascendancy nodes to take next.\n\
63\n\
64Use search_trade to look up item prices on the PoE2 trade site. You can search by \
65item name, base type, category, rarity, and stat filters. Stat filters use human-readable \
66names like \"maximum life\" or \"fire resistance\" — the tool resolves them to trade API IDs \
67automatically. Use this when the user asks \"how much is X worth?\", \"what would an upgrade \
68cost?\", or \"what's available on the market?\".\n\
69Important search_trade rules:\n\
70- The `type` parameter is the base type (e.g. \"Gold Ring\", \"Leather Vest\"). Never pass an \
71empty string — either provide a real base type or omit the parameter entirely.\n\
72- Do NOT use `max_price` unless the user explicitly asks for a budget or price cap. \
73The price filter excludes items listed in other currencies, which hides most results \
74and produces misleading prices.\n\
75\n\
76Use check_currency_price to check exchange rates between currencies. For example, \
77\"how many chaos for an exalted?\" or \"what's the divine:chaos ratio?\". Common currency \
78IDs: chaos, exalted, divine, regal, aug, transmute, vaal, chance, mirror.\n\
79\n\
80Be specific and reference actual numbers from the build data when relevant. \
81If the data doesn't contain enough information to answer, say so.\n\
82\n\
83Path of Exile 2 differences from Path of Exile 1 — do NOT confuse these:\n\
84- There are NO utility flasks. Players have 2 flask slots (life/mana style only).\n\
85- Charms (3 slots) provide passive bonuses and trigger effects — they replace \
86much of what utility flasks did in PoE1.\n\
87- Spirit is a resource that reserves for persistent buffs, auras, and minions.\n\
88- Gear does NOT have gem sockets. Skill gems are equipped independently in \
89dedicated active-gem slots, each with support sockets.\n\
90- Rune sockets on gear provide bonus stats (via socketed runes).\n\
91- Do NOT reference PoE1-specific unique items, support gems, or league mechanics.\n\
92- When recommending items, gems, or tree nodes, verify they exist using the \
93available tools rather than relying on memory.\n\
94\n\
95Use search_gems to look up skill gems in the PoE2 database. You can search by \
96name, filter by type (active or support), and filter by tags (projectile, fire, \
97area, duration, etc.). Always use this tool instead of guessing gem names or tags \
98from memory — PoE2 gems are different from PoE1.\n\
99\n\
100Use search_uniques to look up unique items in the PoE2 database. You can search by \
101name or mod text, filter by equipment slot, and filter by level range. Always use this \
102tool instead of guessing unique item names or stats from memory.\n\
103\n\
104Use list_charms to see all available charm bases in PoE2. Charms auto-activate when a \
105condition is met (e.g. becoming Frozen) and provide a temporary buff. This returns all \
10613 charm bases — no parameters needed. Use this when the user asks about charm options \
107or what charms exist.\n\
108\n\
109Use search_runes to look up runes and soul cores in the PoE2 database. Runes give \
110different bonuses depending on the equipment slot they're socketed into. You can search \
111by name or stat text, and filter by equipment slot. Always use this tool instead of \
112guessing rune names or effects from memory.\n\
113\n\
114**Theorycrafting workflow** for custom gear:\n\
1151. `get_item` — see what's currently equipped in the slot\n\
1162. `search_bases` — find valid base type names for the equipment slot\n\
1173. `search_mods` — find valid mod text (use item type tag from the base's tags). \
118The database shows ranges like `+(5-8) to Strength`; use a specific value like \
119`+8 to Strength` in item text.\n\
1204. `create_item` — construct item text with the exact base name and specific values \
121within mod ranges. Check `matched_mods` and `unmatched_mods` in the response — \
122unmatched mods have no stat effect and should be corrected.\n\
1235. If `unmatched_mods` is non-empty, use `search_mods` to find the correct mod text \
124and re-create the item.\n\
125\n\
126Always use `search_bases` and `search_mods` before creating items — do NOT guess base \
127type names or mod text from memory, as PoE2 data is different from PoE1.";
128
129/// A single turn from a prior conversation. Text only — no tool calls.
130#[derive(Debug, Clone)]
131pub struct ChatMessage {
132    pub role: String,
133    pub content: String,
134}
135
136/// Events yielded by the agent during a response.
137#[non_exhaustive]
138pub enum AgentEvent {
139    /// The agent is calling a tool (yields tool name for progress indication).
140    ToolCall { name: String },
141    /// A tool has returned a result (yields tool name and response size).
142    ToolResult { name: String, size_bytes: usize },
143    /// A token of the final streamed response.
144    Token(String),
145    /// Cumulative token usage across all LLM calls for this response.
146    Usage(Usage),
147    /// The agent has produced a build mutation. Emitted after the final response.
148    BuildMutation { xml: String, label: String },
149    /// Complete reasoning trace for this response. Emitted last.
150    Trace(AgentTrace),
151}
152
153/// Tool-calling build analysis agent.
154///
155/// Wraps an LLM client and a shared PoB parser. Each call to `respond`
156/// runs a ReAct loop: the LLM decides which tools to call, the agent
157/// executes them via the parser, and the results are fed back until
158/// the LLM produces a final answer.
159pub struct ToolAgent {
160    llm: ChatGptClient,
161    parser: Arc<PobParser>,
162    trade: Option<Arc<TradeClient>>,
163}
164
165impl ToolAgent {
166    pub fn new(llm: ChatGptClient, parser: Arc<PobParser>, trade: Option<TradeClient>) -> Self {
167        Self {
168            llm,
169            parser,
170            trade: trade.map(Arc::new),
171        }
172    }
173
174    /// Stream a response to a user question about the given build.
175    ///
176    /// `build_xml` is the raw PoB XML export. The agent loads it into PoB
177    /// on each tool call so queries always reflect the full build.
178    pub fn respond(
179        &self,
180        build_xml: &[u8],
181        message: &str,
182        history: Vec<ChatMessage>,
183    ) -> impl Stream<Item = Result<AgentEvent, LlmError>> + Send {
184        let llm = self.llm.clone();
185        let parser = Arc::clone(&self.parser);
186        let trade = self.trade.clone();
187        let build_xml = build_xml.to_vec();
188        let message = message.to_owned();
189
190        async_stream::try_stream! {
191            let registry = ToolRegistry::new(trade.is_some());
192            let tools = registry.definitions();
193
194            // Build input items from conversation history.
195            let mut input: Vec<serde_json::Value> = Vec::new();
196            let trace_history: Vec<TraceMessage> = history
197                .iter()
198                .filter(|m| m.role == "user" || m.role == "assistant")
199                .map(|m| TraceMessage {
200                    role: m.role.clone(),
201                    content: m.content.clone(),
202                })
203                .collect();
204            for msg in history {
205                match msg.role.as_str() {
206                    "user" | "assistant" => {
207                        input.push(input_message(&msg.role, &msg.content));
208                    }
209                    _ => {}
210                }
211            }
212            input.push(input_message("user", &message));
213
214            let mut trace = TraceBuilder::new(&message, trace_history);
215
216            // Unified streaming loop with response chaining.
217            // Every round streams. Text deltas yield to the user as they arrive.
218            // Function calls are collected and executed after the round completes.
219            // When a round produces text instead of tool calls, we're done.
220            let mut pending_mutation: Option<BuildMutation> = None;
221            let mut previous_response_id: Option<String> = None;
222            let mut cumulative_usage = Usage::default();
223
224            for _ in 0..MAX_TOOL_ROUNDS {
225                // First round: full input, instructions, tools.
226                // Chained rounds: tool results + tools (previous_response_id
227                // carries conversation context, but tools must be re-sent).
228                let (call_input, call_instructions, call_tools) = if previous_response_id.is_some() {
229                    (&input[..], None, Some(&tools[..]))
230                } else {
231                    (&input[..], Some(SYSTEM_PROMPT), Some(&tools[..]))
232                };
233
234                let stream = llm.create_response_stream(
235                    call_input,
236                    call_instructions,
237                    call_tools,
238                    previous_response_id.as_deref(),
239                );
240                tokio::pin!(stream);
241
242                let mut function_calls = Vec::new();
243                while let Some(event) = futures_lite::StreamExt::next(&mut stream).await {
244                    match event? {
245                        ResponseStreamEvent::TextDelta(t) => {
246                            trace.text_delta(&t);
247                            yield AgentEvent::Token(t);
248                        }
249                        ResponseStreamEvent::FunctionCall(fc) => {
250                            function_calls.push(fc);
251                        }
252                        ResponseStreamEvent::ResponseCompleted { id, usage } => {
253                            previous_response_id = Some(id);
254                            if let Some(u) = usage { cumulative_usage += u; }
255                        }
256                    }
257                }
258
259                if function_calls.is_empty() {
260                    // Model produced text (already streamed) — done.
261                    tracing::info!(
262                        input_tokens = cumulative_usage.input_tokens,
263                        output_tokens = cumulative_usage.output_tokens,
264                        cached_tokens = cumulative_usage.cached_tokens(),
265                        total_tokens = cumulative_usage.total_tokens,
266                        "agent response complete"
267                    );
268                    yield AgentEvent::Usage(cumulative_usage);
269                    if let Some(m) = pending_mutation {
270                        trace.build_mutation(&m.label);
271                        yield AgentEvent::BuildMutation { xml: m.xml, label: m.label };
272                    }
273                    yield AgentEvent::Trace(trace.finish(&cumulative_usage));
274                    return;
275                }
276
277                // Yield tool call events, then execute tools.
278                for fc in &function_calls {
279                    tracing::debug!(
280                        tool = %fc.name,
281                        arguments = %fc.arguments,
282                        "tool call"
283                    );
284                    trace.tool_call(&fc.name, &fc.arguments);
285                    yield AgentEvent::ToolCall { name: fc.name.clone() };
286                }
287
288                let mut tool_results = Vec::new();
289                for fc in &function_calls {
290                    let ctx = ToolContext {
291                        parser: &parser,
292                        build_xml: &build_xml,
293                        trade: trade.as_deref(),
294                    };
295                    let result = registry.execute(&ctx, &fc.name, &fc.arguments).await;
296                    let content = match result {
297                        Ok(tool_result) => {
298                            if let Some(m) = tool_result.mutation {
299                                pending_mutation = Some(m);
300                            }
301                            tool_result.response.to_string()
302                        }
303                        Err(e) => format!("{{\"error\": \"{e}\"}}"),
304                    };
305                    tracing::debug!(
306                        tool = %fc.name,
307                        result_bytes = content.len(),
308                        result = %content.chars().take(1000).collect::<String>(),
309                        "tool result"
310                    );
311                    trace.tool_result(&fc.name, &content);
312                    yield AgentEvent::ToolResult {
313                        name: fc.name.clone(),
314                        size_bytes: content.len(),
315                    };
316                    tool_results.push(input_function_call_output(&fc.call_id, &content));
317                }
318
319                // Next round: only tool results (server has the rest via chain).
320                input = tool_results;
321            }
322
323            // Exhausted MAX_TOOL_ROUNDS.
324            tracing::info!(
325                input_tokens = cumulative_usage.input_tokens,
326                output_tokens = cumulative_usage.output_tokens,
327                cached_tokens = cumulative_usage.cached_tokens(),
328                total_tokens = cumulative_usage.total_tokens,
329                "agent response complete"
330            );
331            yield AgentEvent::Usage(cumulative_usage);
332            if let Some(m) = pending_mutation {
333                trace.build_mutation(&m.label);
334                yield AgentEvent::BuildMutation { xml: m.xml, label: m.label };
335            }
336            yield AgentEvent::Trace(trace.finish(&cumulative_usage));
337        }
338    }
339}