Skip to content

Commit f4b40ec

Browse files
fix: address PR #472 review comments — 31 fixes across C++ commons, Swift SDK, and iOS app
**C++ commons (`sdk/runanywhere-commons/`):** - `tool_calling.cpp`: Add `json_value_kind_t` enum + `extract_json_array_raw` helper; flat-args fallback now emits numbers / booleans / null / arrays verbatim instead of coercing them to strings. Track the matched tool-name key so only the specific alias that produced `out_tool_name` is excluded from flat args (e.g. `run_shell`'s `command` is no longer dropped). Return `false` on `out_args_json` malloc failure instead of success-with-null. (QEF-1/15/16/29) - `llamacpp_backend.cpp`: Cast `stop_window[safe_len]` to `uint8_t` before `& 0xC0` mask so signed-char platforms don't invoke UB on UTF-8 continuation-byte detection (both loops). Mirror `generate_stream`'s `partial_utf8_buffer` flush into `generate_from_context` so trailing multi-byte codepoints aren't silently dropped before the final stop_window emit. (QEF-28/31) - `llm_component.cpp`: Special-case empty `ctx.full_text` so cancelled / no-op streams report `completion_tokens = 0` instead of `estimate_tokens("") = 1`. (QEF-26) **Swift SDK (`sdk/runanywhere-swift/`):** - `RunAnywhere+TextGeneration.swift`: Remove explicit `Unmanaged<LLMStreamCallbackContext>.release()` in the non-SUCCESS branch — C++ always calls `errorCallback` which consumes the retained ref via `takeRetainedValue()`, so the explicit release was a double-free. Drop the `Task.isCancelled` no-op in the C token callback (cancellation is atomic-flag based in `llm_component.cpp`). Approximate `thinkingTokens` proportionally by character ratio for both streaming and non-streaming paths instead of assigning the whole `outputTokens`. (QEF-4/17/27) - `RunAnywhere+ToolCalling.swift`: Re-apply `noThinkPrefix` at the top of every follow-up tool-round prompt (was only preserved on the initial build), and pass `cleanPrompt` (not the raw `prompt`) into `buildFollowUpPrompt`. (QEF-18) - `RunAnywhere+VoiceSession.swift`: Move `emit(.turnCompleted(...))` inside the `do` block so errors no longer overwrite `.error` UI state. Make `resumeListening()` idempotent by guarding on `audioCapture.isRecording`. Gate `/no_think` injection on `RunAnywhere.currentLLMModel?.supportsThinking` so non-thinking models don't receive a stray slash command. Only swallow `.playbackInterrupted`; rethrow other `AudioPlaybackError` cases so real TTS failures aren't hidden. (QEF-2/20/21/22) - `RunAnywhere+VoiceAgent.swift`: Use `defer { rac_free(ptr) }` in `voiceAgentSynthesizeSpeech` instead of a bare `free(ptr)` — fixes both the native-memory leak on empty audio and the deallocator mismatch (header contract says use `rac_free`). (QEF-19) - `CppBridge.swift` + `RunAnywhere.swift`: Make `CppBridge.shutdown()` and `RunAnywhere.reset()` `async`, and `await` each component's `destroy()` sequentially before `Telemetry.shutdown()` / `Events.unregister()` — removes the fire-and-forget `Task` that raced with synchronous teardown. (QEF-30) - Make `ThinkingContentParser` + its `extract(from:)` public, add a canonical `strip(from:)` that handles multi-block + trailing-unclosed `<think>` tags so the app doesn't duplicate three divergent stripping implementations. (QEF-5) **iOS example app (`examples/ios/RunAnywhereAI/`):** - `ChatInterfaceView.swift`: Replace `@AppStorage("thinkingModeEnabled")` with `@ObservedObject SettingsViewModel.shared` so the badge and `LLMViewModel.applyThinkingModePrefix()` read/write the same `@Published` source. (QEF-3/7) - `LLMViewModel.swift` + `RAGViewModel.swift`: Delegate `stripThinkTags` / `extractThinkingContent` to the new public SDK `ThinkingContentParser`. (QEF-5) - `LLMViewModel+ToolCalling.swift`: Extend `updateMessageWithToolResult(...)` with `thinkingContent:` and wire SDK-provided thinking content through so the tool-call path no longer silently drops it. (QEF-24) - `LoraExamplePrompts.swift`: Replace the lock-picking prompt with a benign smart-toaster review prompt that still showcases the abliterated LoRA's unfiltered-opinion character. (QEF-6) - `ModelListViewModel.swift` + `SimplifiedModelsView.swift`: Publish `isLoadingModel` and `.disabled(...)` the model-selection rows while a load is in-flight, instead of silently discarding later taps. (QEF-8) - `SettingsViewModel.swift`: Reset `loadedModelSupportsThinking = false` on registry-lookup miss so the Settings UI stops showing the previous model's capability flag. (QEF-9) - `ToolSettingsView.swift`: Calculator tool returns an error dict instead of fabricating `"0"` when no `expression`/`input`/`expr` key is present; adds `isValidMathExpression(_:)` pre-validation (balanced parens, no consecutive operators, no trailing operator, no `..`) before `NSExpression(format:)` so malformed input can't raise an uncatchable Objective-C exception. (QEF-10/11) - `VLMCameraView.swift`: Snapshot `isAutoStreamingEnabled` on scenePhase == inactive/background and restore it on .active so LIVE mode survives foregrounding. (QEF-12) - `VoiceAgentViewModel.swift`: Drop the manual `sessionState = .listening` after `resumeListening()`; handle the state transition in the session event handler to avoid racing with the .listening event stream. (QEF-13) - `VoiceAssistantView.swift`: Decide the mic tap / long-press action synchronously before spawning `Task`, so fast state changes can't turn a "send now" into `resumeListening()`. (QEF-25) - `AdaptiveLayout.swift`: Add `.accessibilityAddTraits(.isButton)`, `.accessibilityLabel`, `.accessibilityHint`, and `.accessibilityAction` on both iOS-26 and legacy `AdaptiveMicButton` branches so the gesture-driven button regains button semantics for VoiceOver / Switch Control. (QEF-14) - `DemoLoRAAdapter.swift`: Set the abliterated LoRA `fileSize` to the real HF-confirmed `17_620_224` bytes instead of `0`. (QEF-23) Verification: `swift build` on the Swift SDK and `cmake --build build --target rac_commons -j 4` on the C++ commons both succeed with zero new warnings; all 5 commons unit test binaries (PlaceholderTest, core_tests, extraction_tests, download_orchestrator_tests, rac_simple_tokenizer_test) pass. The chunker / rag_backend_thread_safety test failures are pre-existing and unrelated (missing `rag_chunker.h` / `rag_backend.h` headers from separate work). Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 5f3ba51 commit f4b40ec

23 files changed

Lines changed: 431 additions & 137 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: 71 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,13 @@ 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+
}
103163
print("Calculator received args: \(args), using expression: '\(expression)'")
104164
// Clean the expression - remove any non-math characters
105165
let cleanedExpression = expression
@@ -119,6 +179,15 @@ class ToolSettingsViewModel: ObservableObject {
119179
]
120180
}
121181

182+
// Strict pre-validation: NSExpression(format:) can throw uncatchable
183+
// ObjC exceptions on malformed input (e.g., "2*/3", "(", "1+").
184+
guard isValidMathExpression(cleanedExpression) else {
185+
return [
186+
"error": .string("Invalid expression syntax"),
187+
"expression": .string(expression)
188+
]
189+
}
190+
122191
let exp = NSExpression(format: cleanedExpression)
123192
if let result = exp.expressionValue(with: nil, context: nil) as? NSNumber {
124193
return [

0 commit comments

Comments
 (0)