From 2f9b00ae2933d0c9be972ee048814fe197dd795f Mon Sep 17 00:00:00 2001 From: mmanningau Date: Thu, 5 Mar 2026 15:13:56 +1100 Subject: [PATCH] Have settled on testing and using the Qwen2.5:1.5b model as it plays much nicer with the JSON requirements - way better than the Gemma models did. Have refined the calls via LlmManager.kt and MediaPipeAdapter.kt and tested using battery, ping and a new weather skill. Half way through updating further to allow for SMS reading and updating the SkillManager.kt again to accommodate this with a two-pass bridge for python calling. --- app/src/main/AndroidManifest.xml | 4 ++ .../java/net/mmanningau/alice/LlmManager.kt | 41 ++++++++---- .../net/mmanningau/alice/MediaPipeAdapter.kt | 6 +- .../net/mmanningau/alice/ModelRegistry.kt | 10 +-- .../java/net/mmanningau/alice/SkillManager.kt | 66 +++++++++++++++---- 5 files changed, 96 insertions(+), 31 deletions(-) diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 22d0308..4ec0375 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -6,6 +6,10 @@ + + + + previewLength) { - userText.take(previewLength).trim() + "..." - } else { - userText - } - // Update the database instantly + val newTitle = if (userText.length > previewLength) userText.take(previewLength).trim() + "..." else userText chatDao?.updateThreadTitle(currentThreadId, newTitle) } - // 1. Save user message to DB and Memory chatDao?.insertMessage(ChatMessageEntity(threadId = currentThreadId, text = userText, isUser = true)) chatHistory.add(UserMessage(userText)) val toolSpecs = SkillManager.loadSkills() + // --- LOOP CONTROL CONSTANTS --- + val MAX_TOOL_ITERATIONS = 5 + var toolIterations = 0 + val executedToolSignatures = mutableSetOf() // Tracks name+args pairs to catch spin loops + var response = currentModel.generate(chatHistory, toolSpecs) var aiMessage: AiMessage = response.content() chatHistory.add(aiMessage) while (aiMessage.hasToolExecutionRequests()) { + + // --- GUARD 1: Hard iteration cap --- + if (toolIterations >= MAX_TOOL_ITERATIONS) { + Log.w("AliceEngine", "Tool loop cap reached after $MAX_TOOL_ITERATIONS iterations. Breaking.") + val fallbackText = "I've reached the maximum number of steps trying to complete this task. Here's what I found so far." + chatDao?.insertMessage(ChatMessageEntity(threadId = currentThreadId, text = fallbackText, isUser = false)) + return fallbackText + } + for (request in aiMessage.toolExecutionRequests()) { val toolName = request.name() val arguments = request.arguments() + + // --- GUARD 2: Duplicate call detection --- + val signature = "$toolName::$arguments" + if (executedToolSignatures.contains(signature)) { + Log.w("AliceEngine", "Duplicate tool call detected for '$toolName'. Breaking loop.") + val fallbackText = "I seem to be going in circles with the '$toolName' tool. Let me stop and give you what I have." + chatDao?.insertMessage(ChatMessageEntity(threadId = currentThreadId, text = fallbackText, isUser = false)) + return fallbackText + } + executedToolSignatures.add(signature) + val toolResult = SkillManager.executeSkill(toolName, arguments) + Log.d("AliceSkill", "TOOL_RESULT from [$toolName]: $toolResult") chatHistory.add(ToolExecutionResultMessage(request.id(), toolName, toolResult)) } + + toolIterations++ response = currentModel.generate(chatHistory, toolSpecs) aiMessage = response.content() chatHistory.add(aiMessage) } - // 2. Save final AI message to DB chatDao?.insertMessage(ChatMessageEntity(threadId = currentThreadId, text = aiMessage.text(), isUser = false)) - return aiMessage.text() } + } \ No newline at end of file diff --git a/app/src/main/java/net/mmanningau/alice/MediaPipeAdapter.kt b/app/src/main/java/net/mmanningau/alice/MediaPipeAdapter.kt index 81695df..a1ada72 100644 --- a/app/src/main/java/net/mmanningau/alice/MediaPipeAdapter.kt +++ b/app/src/main/java/net/mmanningau/alice/MediaPipeAdapter.kt @@ -35,7 +35,7 @@ class MediaPipeAdapter( // THE PUSH: Aggressively demand the Adreno GPU val gpuOptions = LlmInference.LlmInferenceOptions.builder() .setModelPath(modelPath) - .setMaxTokens(4096) + .setMaxTokens(1200) .setPreferredBackend(LlmInference.Backend.GPU) .build() engine = LlmInference.createFromOptions(context, gpuOptions) @@ -50,7 +50,7 @@ class MediaPipeAdapter( val cpuOptions = LlmInference.LlmInferenceOptions.builder() .setModelPath(modelPath) - .setMaxTokens(4096) + .setMaxTokens(1200) .setPreferredBackend(LlmInference.Backend.CPU) .build() engine = LlmInference.createFromOptions(context, cpuOptions) @@ -105,7 +105,7 @@ class MediaPipeAdapter( } } is ToolExecutionResultMessage -> { - promptBuilder.append("user\n[SYSTEM DATA VIA TOOL '${message.toolName()}']: ${message.text()}\nUse this real data to answer the previous question.\n") + promptBuilder.append("user\n[TOOL RESULT: ${message.toolName()}]\n${message.text()}\n\nIMPORTANT: The above is raw data from a tool. Do NOT repeat it verbatim. You must now write a naturally worded and concise response to the user's original question using this data. Summarise it concisely as Alice their helpful AI assistant.\n") } is AiMessage -> { if (message.hasToolExecutionRequests()) { diff --git a/app/src/main/java/net/mmanningau/alice/ModelRegistry.kt b/app/src/main/java/net/mmanningau/alice/ModelRegistry.kt index 9aa7250..0cdb2de 100644 --- a/app/src/main/java/net/mmanningau/alice/ModelRegistry.kt +++ b/app/src/main/java/net/mmanningau/alice/ModelRegistry.kt @@ -64,12 +64,12 @@ object ModelRegistry { downloadUrl = "https://huggingface.co/google/gemma-3n-E2B-it-litert-lm/resolve/main/gemma-3n-E2B-it-int4.litertlm" ), LocalModel( - id = "Gemma3-1B-IT_multi-prefill-seq_q8_ekv4096", - name = "Gemma 3 (1B) Prefill", - sizeMb = 3390, + id = "Qwen2.5-1.5B-Instruct_seq128_q8_ekv1280", + name = "Qwen2.5-1.5B", + sizeMb = 1570, description = "A highly optimised and fine tuned model for agentic tasks and function calling.", - fileName = "Gemma3-1B-IT_multi-prefill-seq_q8_ekv4096.task", - downloadUrl = "https://huggingface.co/litert-community/Gemma3-1B-IT/resolve/main/Gemma3-1B-IT_multi-prefill-seq_q8_ekv4096.task" + fileName = "Qwen2.5-1.5B-Instruct_seq128_q8_ekv1280.task", + downloadUrl = "https://huggingface.co/litert-community/Qwen2.5-1.5B-Instruct/resolve/main/Qwen2.5-1.5B-Instruct_seq128_q8_ekv1280.task" ) ) diff --git a/app/src/main/java/net/mmanningau/alice/SkillManager.kt b/app/src/main/java/net/mmanningau/alice/SkillManager.kt index 81d096c..084e3ba 100644 --- a/app/src/main/java/net/mmanningau/alice/SkillManager.kt +++ b/app/src/main/java/net/mmanningau/alice/SkillManager.kt @@ -1,10 +1,12 @@ package net.mmanningau.alice import android.content.Context +import android.net.Uri import android.util.Log import com.chaquo.python.Python import dev.langchain4j.agent.tool.JsonSchemaProperty import dev.langchain4j.agent.tool.ToolSpecification +import org.json.JSONArray import org.json.JSONObject import java.io.File @@ -12,7 +14,11 @@ object SkillManager { var skillsDirectory: File? = null private set + // *** ADDED: store context for ContentResolver access + private var appContext: Context? = null + fun initialize(context: Context) { + appContext = context.applicationContext // *** ADDED val baseDir = context.getExternalFilesDir(null) val skillsDir = File(baseDir, "Skills") @@ -25,7 +31,7 @@ object SkillManager { fun updateDirectory(newPath: String) { val newDir = File(newPath) if (!newDir.exists()) { - newDir.mkdirs() // Create it if the user typed a new path + newDir.mkdirs() } skillsDirectory = newDir Log.i("AliceSkills", "Skills directory updated to: ${newDir.absolutePath}") @@ -48,13 +54,12 @@ object SkillManager { .name(name) .description(description) - // Parse the expected parameters so the LLM knows what to extract val parameters = json.optJSONObject("parameters") val properties = parameters?.optJSONObject("properties") properties?.keys()?.forEach { key -> val prop = properties.getJSONObject(key) - val type = prop.getString("type") // e.g., "string" + val type = prop.getString("type") val desc = prop.optString("description", "") builder.addParameter( @@ -85,23 +90,60 @@ object SkillManager { val py = Python.getInstance() val builtins = py.builtins - // We create an isolated dictionary for the script to run in. - // This allows you to edit the Python files and have them hot-reload instantly! val globals = py.getModule("builtins").callAttr("dict") - - // Execute the raw script text builtins.callAttr("exec", scriptFile.readText(), globals) - // Find the 'execute' function we mandated in our python script val executeFunc = globals.callAttr("get", "execute") - if (executeFunc == null) return "Error: Python script missing 'def execute(args_json):' function." + if (executeFunc == null) return "Error: Python script missing 'def execute(args):' function." - // Call it and return the string! - executeFunc.call(argumentsJson).toString() + // First call to Python + var result = executeFunc.call(argumentsJson).toString() + + // *** ADDED: Two-pass bridge for skills that need Android ContentResolver + if (result.startsWith("BRIDGE_REQUEST:")) { + val ctx = appContext + if (ctx == null) { + return "Error: SkillManager context not initialized — cannot perform ContentResolver query." + } + + val request = JSONObject(result.removePrefix("BRIDGE_REQUEST:")) + val uri = Uri.parse(request.getString("uri")) + val limit = request.optInt("limit", 10) + val columns = arrayOf("_id", "address", "body", "date", "type", "read") + + val smsArray = JSONArray() + val cursor = ctx.contentResolver.query( + uri, columns, null, null, "date DESC" + ) + + cursor?.use { + var count = 0 + while (it.moveToNext() && count < limit) { + val row = JSONObject() + row.put("address", it.getString(it.getColumnIndexOrThrow("address")) ?: "") + row.put("body", it.getString(it.getColumnIndexOrThrow("body")) ?: "") + row.put("date", it.getString(it.getColumnIndexOrThrow("date")) ?: "") + row.put("type", it.getString(it.getColumnIndexOrThrow("type")) ?: "1") + row.put("read", it.getString(it.getColumnIndexOrThrow("read")) ?: "0") + smsArray.put(row) + count++ + } + } + + Log.i("AliceSkills", "SMS bridge: fetched ${smsArray.length()} messages from $uri") + + // Re-inject the data and call Python a second time + val injectedArgs = JSONObject(argumentsJson.ifBlank { "{}" }) + injectedArgs.put("sms_data", smsArray) + result = executeFunc.call(injectedArgs.toString()).toString() + } + // *** END ADDED + + result } catch (e: Exception) { Log.e("AliceSkills", "Execution failed for $toolName", e) "Error executing skill: ${e.message}" } } -} \ No newline at end of file +}