114
target_os = "linux",
115
target_os = "android"
116
))]
117
-use std::collections::HashMap;
117
+use std::collections::{HashMap, HashSet};
118
#[cfg(any(
119
target_os = "macos",
120
target_os = "ios",
278
target_os = "android"
279
))]
280
impl ChatEngine {
281
- /// Whether the currently loaded model is verified for structured tool calls.
282
- pub async fn tool_calling_support(&self) -> super::types::ToolCallingSupport {
283
- use super::types::ToolCallingSupport;
284
-
285
- let guard = self.inner.lock().await;
286
- let Some(loaded) = guard.as_ref() else {
287
- return ToolCallingSupport::Unknown;
288
- };
289
- let model_id = match &loaded.config {
290
- LoadedModelConfig::Gguf(config) => &config.model_id,
291
- LoadedModelConfig::Uqff(config) => &config.model_id,
292
- #[cfg(target_os = "macos")]
293
- LoadedModelConfig::Isq(config) => &config.model_id,
294
- };
295
- super::models::tool_calling_support(model_id)
296
- }
281
// ── Construction ─────────────────────────────────────────────────────
282
283
/// Create a new engine with no model loaded and no Onde app association.
1318
1319
// ── Tool-aware inference (Rust-only) ─────────────────────────────────
1320
1321
+ /// Whether the currently loaded model is verified for structured tool calls.
1322
+ ///
1323
+ /// Returns [`Unsupported`](super::types::ToolCallingSupport::Unsupported) when no model
1324
+ /// is loaded: an engine with nothing in it cannot produce tool calls, and
1325
+ /// reporting `Unknown` there would conflate an empty engine with a model
1326
+ /// repository Onde has not catalogued.
1327
+ pub async fn tool_calling_support(&self) -> super::types::ToolCallingSupport {
1328
+ use super::types::ToolCallingSupport;
1329
+
1330
+ let guard = self.inner.lock().await;
1331
+ let Some(loaded) = guard.as_ref() else {
1332
+ return ToolCallingSupport::Unsupported;
1333
+ };
1334
+ let model_id = match &loaded.config {
1335
+ LoadedModelConfig::Gguf(config) => &config.model_id,
1336
+ LoadedModelConfig::Uqff(config) => &config.model_id,
1337
+ #[cfg(target_os = "macos")]
1338
+ LoadedModelConfig::Isq(config) => &config.model_id,
1339
+ };
1340
+ super::models::tool_calling_support(model_id)
1341
+ }
1342
+
1343
/// Send a user message with tool definitions available. Non-streaming.
1344
///
1345
/// If the model decides to call tools, [`ToolAwareResult::tool_calls`]
1509
/// Agent hosts use this when a turn is cancelled after the assistant has
1510
/// requested tools. Recording a result for every request keeps the history
1511
/// structurally valid for the next turn.
1506
- pub async fn record_tool_results(&self, results: Vec<ToolResult>) {
1512
+ ///
1513
+ /// Only results answering a call the assistant actually requested, and not
1514
+ /// already recorded, are appended. Anything else is dropped with a warning:
1515
+ /// a `ToolResult` with no matching `AssistantToolCall` ahead of it builds a
1516
+ /// message sequence the model cannot interpret, and silently writing one
1517
+ /// corrupts every subsequent turn rather than the current one. Returns how
1518
+ /// many results were appended.
1519
+ pub async fn record_tool_results(&self, results: Vec<ToolResult>) -> usize {
1520
let mut guard = self.inner.lock().await;
1508
- if let Some(loaded) = guard.as_mut() {
1509
- for result in results {
1510
- loaded.history.push(HistoryEntry::ToolResult {
1511
- tool_call_id: result.tool_call_id,
1512
- content: result.content,
1513
- });
1521
+ let Some(loaded) = guard.as_mut() else {
1522
+ return 0;
1523
+ };
1524
+
1525
+ // Walk back over results already recorded for this round to find the
1526
+ // assistant turn they answer. A `Text` entry means the round is closed.
1527
+ let mut requested: Vec<String> = Vec::new();
1528
+ let mut seen: HashSet<String> = HashSet::new();
1529
+ for entry in loaded.history.iter().rev() {
1530
+ match entry {
1531
+ HistoryEntry::ToolResult { tool_call_id, .. } => {
1532
+ seen.insert(tool_call_id.clone());
1533
+ }
1534
+ HistoryEntry::AssistantToolCall { tool_calls, .. } => {
1535
+ requested = tool_calls.iter().map(|call| call.id.clone()).collect();
1536
+ break;
1537
+ }
1538
+ HistoryEntry::Text(_) => break,
1539
+ }
1540
+ }
1541
+
1542
+ if requested.is_empty() {
1543
+ log::warn!(
1544
+ "ChatEngine: record_tool_results found no assistant tool call to answer — dropping {} result(s)",
1545
+ results.len()
1546
+ );
1547
+ return 0;
1548
+ }
1549
+
1550
+ let mut appended = 0;
1551
+ for result in results {
1552
+ if !requested.contains(&result.tool_call_id) {
1553
+ log::warn!(
1554
+ "ChatEngine: dropping tool result for unrequested call {:?}",
1555
+ result.tool_call_id
1556
+ );
1557
+ continue;
1558
}
1559
+ if !seen.insert(result.tool_call_id.clone()) {
1560
+ log::warn!(
1561
+ "ChatEngine: dropping duplicate tool result for call {:?}",
1562
+ result.tool_call_id
1563
+ );
1564
+ continue;
1565
+ }
1566
+ loaded.history.push(HistoryEntry::ToolResult {
1567
+ tool_call_id: result.tool_call_id,
1568
+ content: result.content,
1569
+ });
1570
+ appended += 1;
1571
}
1572
+ appended
1573
}
1574
1575
/// Stream tool execution results back to the model.
2127
pub async fn push_history(&self, _message: ChatMessage) {}
2128
2129
pub async fn tool_calling_support(&self) -> super::types::ToolCallingSupport {
2073
- super::types::ToolCallingSupport::Unknown
2130
+ // Inference cannot run on this platform at all, so tool calling is not
2131
+ // merely unverified — it is unavailable.
2132
+ super::types::ToolCallingSupport::Unsupported
2133
}
2134
2076
- pub async fn record_tool_results(&self, _results: Vec<ToolResult>) {}
2135
+ pub async fn record_tool_results(&self, _results: Vec<ToolResult>) -> usize {
2136
+ 0
2137
+ }
2138
2139
pub async fn send_message(
2140
&self,