use std::sync::Arc;
use futures_core::Stream;
use crate::llm::{
input_function_call_output, input_message, ChatGptClient, LlmError, ResponseStreamEvent, Usage,
};
use crate::pob_parser::PobParser;
use crate::tools::{BuildMutation, ToolContext, ToolRegistry};
use crate::trace::{AgentTrace, TraceBuilder, TraceMessage};
use crate::trade::TradeClient;
const MAX_TOOL_ROUNDS: usize = 10;
const SYSTEM_PROMPT: &str = "\
You are a Path of Exile 2 build analysis assistant. The user has uploaded \
their Path of Building export.\n\
\n\
You have tools to inspect the build data. Use them to answer the user's \
questions accurately — do NOT guess at numbers.\n\
\n\
Start by calling get_build_stats to get an overview of the build's offense, \
defense, and resources. Then use get_skill_list or get_config if needed \
to answer the user's specific question.\n\
\n\
Use get_equipped_items to see all gear across every slot in one call — names, \
bases, rarity, and mod lines for filled slots, plus empty slot markers and \
socketed jewels. Use this for broad gear questions before diving into specifics.\n\
\n\
Use get_item to inspect a specific equipment slot when the user asks about \
their gear, an item's mods, or how a particular slot could be upgraded. \
Do not call get_item unless the question is about specific equipment.\n\
\n\
Use analyze_gear_mods for deep mod analysis on a specific gear slot. Unlike \
get_item (which just shows mod text), analyze_gear_mods shows each mod's \
tier, roll quality, affix name, current vs max range, and whether upgrades \
are available at the item's level. Use this when the user asks about mod \
tiers, roll quality, crafting upgrades, or \"how good are my rolls\". \
Not applicable to unique items, flasks, or charms.\n\
\n\
Use get_passive_tree when the user asks about their passive tree, allocated \
nodes, keystones, notables, ascendancy choices, masteries, or jewel sockets. \
It returns all allocated nodes categorized by type.\n\
\n\
Use get_jewel to inspect a jewel socketed in a passive tree socket. First call \
get_passive_tree to get the jewel_sockets list with node IDs, then call \
get_jewel with the node_id to see the jewel's name, base, rarity, and mods.\n\
\n\
Use query_passive_stats to find how much of a specific stat comes from allocated \
passives and what's available nearby on the tree. Provide a stat pattern like \
\"fire damage\" or \"maximum life\". Optionally set radius (default 3) to control \
how far to search from current allocation.\n\
\n\
Use get_unallocated_ascendancy to see which ascendancy nodes the character has \
allocated and which are still available. Returns both primary and secondary \
ascendancy nodes with node names, types, and stats. Use this when recommending \
ascendancy choices or when the user asks what ascendancy nodes to take next.\n\
\n\
Use search_trade to look up item prices on the PoE2 trade site. You can search by \
item name, base type, category, rarity, and stat filters. Stat filters use human-readable \
names like \"maximum life\" or \"fire resistance\" — the tool resolves them to trade API IDs \
automatically. Use this when the user asks \"how much is X worth?\", \"what would an upgrade \
cost?\", or \"what's available on the market?\".\n\
Important search_trade rules:\n\
- The `type` parameter is the base type (e.g. \"Gold Ring\", \"Leather Vest\"). Never pass an \
empty string — either provide a real base type or omit the parameter entirely.\n\
- Do NOT use `max_price` unless the user explicitly asks for a budget or price cap. \
The price filter excludes items listed in other currencies, which hides most results \
and produces misleading prices.\n\
\n\
Use check_currency_price to check exchange rates between currencies. For example, \
\"how many chaos for an exalted?\" or \"what's the divine:chaos ratio?\". Common currency \
IDs: chaos, exalted, divine, regal, aug, transmute, vaal, chance, mirror.\n\
\n\
Be specific and reference actual numbers from the build data when relevant. \
If the data doesn't contain enough information to answer, say so.\n\
\n\
Path of Exile 2 differences from Path of Exile 1 — do NOT confuse these:\n\
- There are NO utility flasks. Players have 2 flask slots (life/mana style only).\n\
- Charms (3 slots) provide passive bonuses and trigger effects — they replace \
much of what utility flasks did in PoE1.\n\
- Spirit is a resource that reserves for persistent buffs, auras, and minions.\n\
- Gear does NOT have gem sockets. Skill gems are equipped independently in \
dedicated active-gem slots, each with support sockets.\n\
- Rune sockets on gear provide bonus stats (via socketed runes).\n\
- Do NOT reference PoE1-specific unique items, support gems, or league mechanics.\n\
- When recommending items, gems, or tree nodes, verify they exist using the \
available tools rather than relying on memory.\n\
\n\
Use search_gems to look up skill gems in the PoE2 database. You can search by \
name, filter by type (active or support), and filter by tags (projectile, fire, \
area, duration, etc.). Always use this tool instead of guessing gem names or tags \
from memory — PoE2 gems are different from PoE1.\n\
\n\
Use search_uniques to look up unique items in the PoE2 database. You can search by \
name or mod text, filter by equipment slot, and filter by level range. Always use this \
tool instead of guessing unique item names or stats from memory.\n\
\n\
Use list_charms to see all available charm bases in PoE2. Charms auto-activate when a \
condition is met (e.g. becoming Frozen) and provide a temporary buff. This returns all \
13 charm bases — no parameters needed. Use this when the user asks about charm options \
or what charms exist.\n\
\n\
Use search_runes to look up runes and soul cores in the PoE2 database. Runes give \
different bonuses depending on the equipment slot they're socketed into. You can search \
by name or stat text, and filter by equipment slot. Always use this tool instead of \
guessing rune names or effects from memory.\n\
\n\
**Theorycrafting workflow** for custom gear:\n\
1. `get_item` — see what's currently equipped in the slot\n\
2. `search_bases` — find valid base type names for the equipment slot\n\
3. `search_mods` — find valid mod text (use item type tag from the base's tags). \
The database shows ranges like `+(5-8) to Strength`; use a specific value like \
`+8 to Strength` in item text.\n\
4. `create_item` — construct item text with the exact base name and specific values \
within mod ranges. Check `matched_mods` and `unmatched_mods` in the response — \
unmatched mods have no stat effect and should be corrected.\n\
5. If `unmatched_mods` is non-empty, use `search_mods` to find the correct mod text \
and re-create the item.\n\
\n\
Always use `search_bases` and `search_mods` before creating items — do NOT guess base \
type names or mod text from memory, as PoE2 data is different from PoE1.";
#[derive(Debug, Clone)]
pub struct ChatMessage {
pub role: String,
pub content: String,
}
#[non_exhaustive]
pub enum AgentEvent {
ToolCall { name: String },
ToolResult { name: String, size_bytes: usize },
Token(String),
Usage(Usage),
BuildMutation { xml: String, label: String },
Trace(AgentTrace),
}
pub struct ToolAgent {
llm: ChatGptClient,
parser: Arc<PobParser>,
trade: Option<Arc<TradeClient>>,
}
impl ToolAgent {
pub fn new(llm: ChatGptClient, parser: Arc<PobParser>, trade: Option<TradeClient>) -> Self {
Self {
llm,
parser,
trade: trade.map(Arc::new),
}
}
pub fn respond(
&self,
build_xml: &[u8],
message: &str,
history: Vec<ChatMessage>,
) -> impl Stream<Item = Result<AgentEvent, LlmError>> + Send {
let llm = self.llm.clone();
let parser = Arc::clone(&self.parser);
let trade = self.trade.clone();
let build_xml = build_xml.to_vec();
let message = message.to_owned();
async_stream::try_stream! {
let registry = ToolRegistry::new(trade.is_some());
let tools = registry.definitions();
let mut input: Vec<serde_json::Value> = Vec::new();
let trace_history: Vec<TraceMessage> = history
.iter()
.filter(|m| m.role == "user" || m.role == "assistant")
.map(|m| TraceMessage {
role: m.role.clone(),
content: m.content.clone(),
})
.collect();
for msg in history {
match msg.role.as_str() {
"user" | "assistant" => {
input.push(input_message(&msg.role, &msg.content));
}
_ => {}
}
}
input.push(input_message("user", &message));
let mut trace = TraceBuilder::new(&message, trace_history);
let mut pending_mutation: Option<BuildMutation> = None;
let mut previous_response_id: Option<String> = None;
let mut cumulative_usage = Usage::default();
for _ in 0..MAX_TOOL_ROUNDS {
let (call_input, call_instructions, call_tools) = if previous_response_id.is_some() {
(&input[..], None, Some(&tools[..]))
} else {
(&input[..], Some(SYSTEM_PROMPT), Some(&tools[..]))
};
let stream = llm.create_response_stream(
call_input,
call_instructions,
call_tools,
previous_response_id.as_deref(),
);
tokio::pin!(stream);
let mut function_calls = Vec::new();
while let Some(event) = futures_lite::StreamExt::next(&mut stream).await {
match event? {
ResponseStreamEvent::TextDelta(t) => {
trace.text_delta(&t);
yield AgentEvent::Token(t);
}
ResponseStreamEvent::FunctionCall(fc) => {
function_calls.push(fc);
}
ResponseStreamEvent::ResponseCompleted { id, usage } => {
previous_response_id = Some(id);
if let Some(u) = usage { cumulative_usage += u; }
}
}
}
if function_calls.is_empty() {
tracing::info!(
input_tokens = cumulative_usage.input_tokens,
output_tokens = cumulative_usage.output_tokens,
cached_tokens = cumulative_usage.cached_tokens(),
total_tokens = cumulative_usage.total_tokens,
"agent response complete"
);
yield AgentEvent::Usage(cumulative_usage);
if let Some(m) = pending_mutation {
trace.build_mutation(&m.label);
yield AgentEvent::BuildMutation { xml: m.xml, label: m.label };
}
yield AgentEvent::Trace(trace.finish(&cumulative_usage));
return;
}
for fc in &function_calls {
tracing::debug!(
tool = %fc.name,
arguments = %fc.arguments,
"tool call"
);
trace.tool_call(&fc.name, &fc.arguments);
yield AgentEvent::ToolCall { name: fc.name.clone() };
}
let mut tool_results = Vec::new();
for fc in &function_calls {
let ctx = ToolContext {
parser: &parser,
build_xml: &build_xml,
trade: trade.as_deref(),
};
let result = registry.execute(&ctx, &fc.name, &fc.arguments).await;
let content = match result {
Ok(tool_result) => {
if let Some(m) = tool_result.mutation {
pending_mutation = Some(m);
}
tool_result.response.to_string()
}
Err(e) => format!("{{\"error\": \"{e}\"}}"),
};
tracing::debug!(
tool = %fc.name,
result_bytes = content.len(),
result = %content.chars().take(1000).collect::<String>(),
"tool result"
);
trace.tool_result(&fc.name, &content);
yield AgentEvent::ToolResult {
name: fc.name.clone(),
size_bytes: content.len(),
};
tool_results.push(input_function_call_output(&fc.call_id, &content));
}
input = tool_results;
}
tracing::info!(
input_tokens = cumulative_usage.input_tokens,
output_tokens = cumulative_usage.output_tokens,
cached_tokens = cumulative_usage.cached_tokens(),
total_tokens = cumulative_usage.total_tokens,
"agent response complete"
);
yield AgentEvent::Usage(cumulative_usage);
if let Some(m) = pending_mutation {
trace.build_mutation(&m.label);
yield AgentEvent::BuildMutation { xml: m.xml, label: m.label };
}
yield AgentEvent::Trace(trace.finish(&cumulative_usage));
}
}
}