Skip to content

Commit df983f6

Browse files
Merge PR #472: thinking mode + tool-calling fixes + iOS polish (revived from #454)
Adds iOS thinking-mode end-to-end, tool-calling robustness fixes in C++ commons, voice-session interrupt/resume, and iOS demo polish. Conflict resolution: - iOS example files: combined pr-472's thinking-mode + tool-calling fixes with #476's polished UI and safer error handling. - Swift SDK: took pr-472's idempotent resumeListening guard + async shutdown pattern; kept #476's ThinkingContentParser split-token accounting. - C++ commons: took pr-472's JSON value-kind dispatch in tool_calling.cpp; kept #476's UTF-8 safety + pr-472's empty-text guard in llm_component.cpp; added missing <climits>/<cstdint> headers for llamacpp_backend.cpp. - Kept safer example LoRA prompt; corrected placeholder fileSize: 0. - Removed duplicate scalar-handling block in tool_calling.cpp that the agent left behind (old *out_is_object interface vs new JSON_VALUE_LITERAL). Original author: @VyasGuru (via sanchitmonga22 revival) Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2 parents 02123f4 + f4b40ec commit df983f6

23 files changed

Lines changed: 417 additions & 162 deletions

examples/ios/RunAnywhereAI/RunAnywhereAI/Features/Chat/Models/DemoLoRAAdapter.swift

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -76,7 +76,7 @@ enum LoRAAdapterCatalog {
7676
downloadURL: URL(string: "https://huggingface.co/Void2377/qwen-lora-gguf/resolve/main/qwen2.5-0.5b-abliterated-lora-f16.gguf")!,
7777
filename: "qwen2.5-0.5b-abliterated-lora-f16.gguf",
7878
compatibleModelIds: ["qwen2.5-0.5b-base-q8_0"],
79-
fileSize: 0,
79+
fileSize: 17_620_224,
8080
defaultScale: 1.0
8181
),
8282
]

examples/ios/RunAnywhereAI/RunAnywhereAI/Features/Chat/Models/LoraExamplePrompts.swift

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,7 @@ enum LoraExamplePrompts {
2929
],
3030
"qwen2.5-0.5b-abliterated-lora-f16.gguf": [
3131
"What are some controversial topics people often debate about?",
32-
"Explain how lock picking works in detail",
32+
"Write a brutally honest product review for a fictional smart toaster",
3333
],
3434
]
3535

examples/ios/RunAnywhereAI/RunAnywhereAI/Features/Chat/ViewModels/LLMViewModel+ToolCalling.swift

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -69,13 +69,16 @@ extension LLMViewModel {
6969
toolCallInfo = nil
7070
}
7171

72-
// Strip any residual <think> tags before displaying
73-
let displayText = Self.stripThinkTags(from: result.text)
72+
// Split `<think>...</think>` content from the response so the UI can render
73+
// the thinking block separately and avoid silently dropping SDK-provided
74+
// thinking content on the tool-calling path.
75+
let (displayText, thinkingContent) = ThinkingContentParser.extract(from: result.text)
7476

7577
// Update the message with the result
7678
await updateMessageWithToolResult(
7779
at: messageIndex,
7880
text: displayText,
81+
thinkingContent: thinkingContent,
7982
toolCallInfo: toolCallInfo
8083
)
8184
}
@@ -85,6 +88,7 @@ extension LLMViewModel {
8588
func updateMessageWithToolResult(
8689
at index: Int,
8790
text: String,
91+
thinkingContent: String?,
8892
toolCallInfo: ToolCallInfo?
8993
) async {
9094
await MainActor.run {
@@ -103,7 +107,7 @@ extension LLMViewModel {
103107
id: currentMessage.id,
104108
role: currentMessage.role,
105109
content: text,
106-
thinkingContent: nil,
110+
thinkingContent: thinkingContent,
107111
timestamp: currentMessage.timestamp,
108112
analytics: nil, // Tool calling doesn't use standard analytics
109113
modelInfo: modelInfo,

examples/ios/RunAnywhereAI/RunAnywhereAI/Features/Chat/ViewModels/LLMViewModel.swift

Lines changed: 3 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -575,18 +575,9 @@ final class LLMViewModel {
575575
}
576576
}
577577

578+
/// Thin pass-through to the SDK's canonical `ThinkingContentParser.strip(from:)`
579+
/// so the app has a single source of truth for `<think>` tag handling.
578580
static func stripThinkTags(from text: String) -> String {
579-
var result = text
580-
// Remove complete <think>...</think> blocks
581-
while let startRange = result.range(of: "<think>"),
582-
let endRange = result.range(of: "</think>"),
583-
startRange.upperBound <= endRange.lowerBound {
584-
result.removeSubrange(startRange.lowerBound..<endRange.upperBound)
585-
}
586-
if let trailingStart = result.range(of: "<think>", options: .backwards),
587-
result.range(of: "</think>", range: trailingStart.upperBound..<result.endIndex) == nil {
588-
result = String(result[result.startIndex..<trailingStart.lowerBound])
589-
}
590-
return result.trimmingCharacters(in: .whitespacesAndNewlines)
581+
ThinkingContentParser.strip(from: text)
591582
}
592583
}

examples/ios/RunAnywhereAI/RunAnywhereAI/Features/Chat/Views/ChatInterfaceView.swift

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,7 @@ struct ChatInterfaceView: View {
3232
@State private var pendingLoRAURL: URL?
3333
@State private var loraScale: Float = 1.0
3434
@ObservedObject private var toolSettingsViewModel = ToolSettingsViewModel.shared
35-
@AppStorage("thinkingModeEnabled") private var thinkingModeEnabled = false
35+
@ObservedObject private var settingsViewModel = SettingsViewModel.shared
3636
@FocusState private var isTextFieldFocused: Bool
3737

3838
private let logger = Logger(
@@ -449,7 +449,7 @@ extension ChatInterfaceView {
449449

450450
// Status badges (thinking mode + tool calling + LoRA)
451451
HStack(spacing: 8) {
452-
if thinkingModeEnabled && viewModel.loadedModelSupportsThinking {
452+
if settingsViewModel.thinkingModeEnabled && viewModel.loadedModelSupportsThinking {
453453
thinkingModeBadge
454454
}
455455

@@ -465,7 +465,7 @@ extension ChatInterfaceView {
465465
loraAddButton
466466
}
467467
}
468-
.padding(.top, ((thinkingModeEnabled && viewModel.loadedModelSupportsThinking) || viewModel.useToolCalling || !viewModel.loraAdapters.isEmpty || hasModelSelected) ? 8 : 0)
468+
.padding(.top, ((settingsViewModel.thinkingModeEnabled && viewModel.loadedModelSupportsThinking) || viewModel.useToolCalling || !viewModel.loraAdapters.isEmpty || hasModelSelected) ? 8 : 0)
469469

470470
HStack(spacing: AppSpacing.mediumLarge) {
471471
TextField("Type a message...", text: $viewModel.currentInput, axis: .vertical)
@@ -501,7 +501,7 @@ extension ChatInterfaceView {
501501

502502
var thinkingModeBadge: some View {
503503
Button {
504-
thinkingModeEnabled.toggle()
504+
settingsViewModel.thinkingModeEnabled.toggle()
505505
} label: {
506506
HStack(spacing: 6) {
507507
Image(systemName: "lightbulb.min.fill")

examples/ios/RunAnywhereAI/RunAnywhereAI/Features/Models/ModelListViewModel.swift

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -129,7 +129,7 @@ class ModelListViewModel: ObservableObject {
129129
await loadModelsFromRegistry()
130130
}
131131

132-
private var isLoadingModel = false
132+
@Published private(set) var isLoadingModel = false
133133

134134
/// Select and load a model
135135
func selectModel(_ model: ModelInfo) async {

examples/ios/RunAnywhereAI/RunAnywhereAI/Features/Models/SimplifiedModelsView.swift

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -137,6 +137,7 @@ struct SimplifiedModelsView: View {
137137
SimplifiedModelRow(
138138
model: model,
139139
isSelected: selectedModel?.id == model.id,
140+
isLoadingModel: viewModel.isLoadingModel,
140141
onDownloadCompleted: {
141142
Task {
142143
await viewModel.loadModels()
@@ -178,6 +179,7 @@ struct SimplifiedModelsView: View {
178179
private struct SimplifiedModelRow: View {
179180
let model: ModelInfo
180181
let isSelected: Bool
182+
let isLoadingModel: Bool
181183
let onDownloadCompleted: () -> Void
182184
let onSelectModel: () -> Void
183185
let onModelUpdated: () -> Void
@@ -299,7 +301,7 @@ private struct SimplifiedModelRow: View {
299301
.buttonStyle(.borderedProminent)
300302
.tint(AppColors.primaryAccent)
301303
.controlSize(.small)
302-
.disabled(isSelected)
304+
.disabled(isSelected || isLoadingModel)
303305
} else if model.localPath == nil {
304306
if isDownloading {
305307
ProgressView()
@@ -339,6 +341,7 @@ private struct SimplifiedModelRow: View {
339341
.buttonStyle(.borderedProminent)
340342
.tint(AppColors.primaryAccent)
341343
.controlSize(.small)
344+
.disabled(isLoadingModel)
342345
}
343346
}
344347
}

examples/ios/RunAnywhereAI/RunAnywhereAI/Features/RAG/ViewModels/RAGViewModel.swift

Lines changed: 5 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -34,32 +34,18 @@ struct RAGMessage: Identifiable {
3434
}
3535

3636
// MARK: - Think Tag Helpers
37+
//
38+
// Thin pass-throughs to the SDK's canonical `ThinkingContentParser` so the
39+
// app has a single source of truth for `<think>` tag handling.
3740

3841
/// Extract the content inside `<think>...</think>` tags.
3942
static func extractThinkingContent(from text: String) -> String? {
40-
guard let startRange = text.range(of: "<think>"),
41-
let endRange = text.range(of: "</think>"),
42-
startRange.upperBound <= endRange.lowerBound else {
43-
return nil
44-
}
45-
let content = String(text[startRange.upperBound..<endRange.lowerBound])
46-
.trimmingCharacters(in: .whitespacesAndNewlines)
47-
return content.isEmpty ? nil : content
43+
ThinkingContentParser.extract(from: text).thinking
4844
}
4945

5046
/// Strip all `<think>...</think>` blocks and trailing incomplete `<think>` tags.
5147
static func stripThinkTags(from text: String) -> String {
52-
var result = text
53-
while let startRange = result.range(of: "<think>"),
54-
let endRange = result.range(of: "</think>"),
55-
startRange.upperBound <= endRange.lowerBound {
56-
result.removeSubrange(startRange.lowerBound..<endRange.upperBound)
57-
}
58-
if let trailingStart = result.range(of: "<think>", options: .backwards),
59-
result.range(of: "</think>", range: trailingStart.upperBound..<result.endIndex) == nil {
60-
result = String(result[result.startIndex..<trailingStart.lowerBound])
61-
}
62-
return result.trimmingCharacters(in: .whitespacesAndNewlines)
48+
ThinkingContentParser.strip(from: text)
6349
}
6450
}
6551

examples/ios/RunAnywhereAI/RunAnywhereAI/Features/Settings/SettingsViewModel.swift

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -122,6 +122,9 @@ class SettingsViewModel: ObservableObject {
122122
if let model = ModelListViewModel.shared.availableModels.first(where: { $0.id == modelId }) {
123123
loadedModelSupportsThinking = model.supportsThinking
124124
logger.info("LLM loaded (\(modelId)), supportsThinking: \(model.supportsThinking)")
125+
} else {
126+
loadedModelSupportsThinking = false
127+
logger.warning("LLM loaded (\(modelId)), but it was not found in the registry")
125128
}
126129
case "llm_model_unloaded":
127130
loadedModelSupportsThinking = false

examples/ios/RunAnywhereAI/RunAnywhereAI/Features/Settings/ToolSettingsView.swift

Lines changed: 63 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,61 @@
88
import SwiftUI
99
import RunAnywhere
1010

11+
// MARK: - Math Expression Validation
12+
13+
/// Strict syntactic validation for math expressions before evaluation.
14+
/// `NSExpression(format:)` can raise uncatchable ObjC exceptions on malformed
15+
/// input that passes a simple character whitelist (e.g., "2*/3", "(", "1+").
16+
/// This routine rejects the common unsafe patterns.
17+
fileprivate func isValidMathExpression(_ expr: String) -> Bool {
18+
let trimmed = expr.trimmingCharacters(in: .whitespacesAndNewlines)
19+
guard !trimmed.isEmpty else { return false }
20+
21+
let operators: Set<Character> = ["+", "-", "*", "/"]
22+
// Chars after which a unary "-" is acceptable.
23+
let unaryMinusContext: Set<Character> = ["(", "+", "-", "*", "/"]
24+
25+
var parenDepth = 0
26+
var prevNonSpace: Character? = nil
27+
var prevWasDot = false
28+
29+
for ch in trimmed {
30+
if ch == " " { continue }
31+
32+
if ch == "(" {
33+
parenDepth += 1
34+
} else if ch == ")" {
35+
parenDepth -= 1
36+
if parenDepth < 0 { return false }
37+
}
38+
39+
// Consecutive decimal dots (e.g., "1..2").
40+
if ch == "." {
41+
if prevWasDot { return false }
42+
prevWasDot = true
43+
} else {
44+
prevWasDot = false
45+
}
46+
47+
// Consecutive operators (allow unary "-" after operators or "(").
48+
if operators.contains(ch), let prev = prevNonSpace, operators.contains(prev) {
49+
if !(ch == "-" && unaryMinusContext.contains(prev)) {
50+
return false
51+
}
52+
}
53+
54+
prevNonSpace = ch
55+
}
56+
57+
// Balanced parentheses.
58+
guard parenDepth == 0 else { return false }
59+
60+
// No trailing operator.
61+
if let last = prevNonSpace, operators.contains(last) { return false }
62+
63+
return true
64+
}
65+
1166
// MARK: - Tool Settings View Model
1267

1368
@MainActor
@@ -85,7 +140,7 @@ class ToolSettingsViewModel: ObservableObject {
85140
),
86141
executor: { args in
87142
// Extract expression from args, handling both string and number ToolValue types
88-
let expression: String = {
143+
let expression: String? = {
89144
let keys = ["expression", "input", "expr"]
90145
for key in keys {
91146
if let val = args[key] {
@@ -98,8 +153,14 @@ class ToolSettingsViewModel: ObservableObject {
98153
if let s = val.stringValue { return s }
99154
if let n = val.numberValue { return "\(n)" }
100155
}
101-
return "0"
156+
return nil
102157
}()
158+
guard let expression, !expression.isEmpty else {
159+
return [
160+
"error": .string("Missing expression argument")
161+
]
162+
}
163+
print("Calculator received args: \(args), using expression: '\(expression)'")
103164
// Clean the expression - remove any non-math characters
104165
let cleanedExpression = expression
105166
.replacingOccurrences(of: "=", with: "")

0 commit comments

Comments
 (0)