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.

This commit is contained in:
2026-03-05 15:13:56 +11:00
parent 8ba9cb7a62
commit 2f9b00ae29
5 changed files with 96 additions and 31 deletions

View File

@@ -6,6 +6,10 @@
<uses-permission android:name="android.permission.BIND_ACCESSIBILITY_SERVICE" /> <uses-permission android:name="android.permission.BIND_ACCESSIBILITY_SERVICE" />
<uses-permission android:name="android.permission.READ_SMS"/>
<uses-permission android:name="android.permission.RECEIVE_SMS"/> <!-- for new messages -->
<application <application
android:name="AliceApp" android:name="AliceApp"
android:usesCleartextTraffic="true" android:usesCleartextTraffic="true"

View File

@@ -141,44 +141,63 @@ object LlmManager {
if (currentMode == "Local" && chatModel == null) return "System: Local engine is selected but not properly initialized or unsupported file format." if (currentMode == "Local" && chatModel == null) return "System: Local engine is selected but not properly initialized or unsupported file format."
val currentModel = chatModel ?: return "Error: LLM engine not initialized." val currentModel = chatModel ?: return "Error: LLM engine not initialized."
// If the history size is 1, it means only the System prompt exists. This is the first message!
if (chatHistory.size == 1) { if (chatHistory.size == 1) {
// Take the first 25 characters. If it's longer, add "..."
val previewLength = 25 val previewLength = 25
val newTitle = if (userText.length > previewLength) { val newTitle = if (userText.length > previewLength) userText.take(previewLength).trim() + "..." else userText
userText.take(previewLength).trim() + "..."
} else {
userText
}
// Update the database instantly
chatDao?.updateThreadTitle(currentThreadId, newTitle) chatDao?.updateThreadTitle(currentThreadId, newTitle)
} }
// 1. Save user message to DB and Memory
chatDao?.insertMessage(ChatMessageEntity(threadId = currentThreadId, text = userText, isUser = true)) chatDao?.insertMessage(ChatMessageEntity(threadId = currentThreadId, text = userText, isUser = true))
chatHistory.add(UserMessage(userText)) chatHistory.add(UserMessage(userText))
val toolSpecs = SkillManager.loadSkills() val toolSpecs = SkillManager.loadSkills()
// --- LOOP CONTROL CONSTANTS ---
val MAX_TOOL_ITERATIONS = 5
var toolIterations = 0
val executedToolSignatures = mutableSetOf<String>() // Tracks name+args pairs to catch spin loops
var response = currentModel.generate(chatHistory, toolSpecs) var response = currentModel.generate(chatHistory, toolSpecs)
var aiMessage: AiMessage = response.content() var aiMessage: AiMessage = response.content()
chatHistory.add(aiMessage) chatHistory.add(aiMessage)
while (aiMessage.hasToolExecutionRequests()) { 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()) { for (request in aiMessage.toolExecutionRequests()) {
val toolName = request.name() val toolName = request.name()
val arguments = request.arguments() 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) val toolResult = SkillManager.executeSkill(toolName, arguments)
Log.d("AliceSkill", "TOOL_RESULT from [$toolName]: $toolResult")
chatHistory.add(ToolExecutionResultMessage(request.id(), toolName, toolResult)) chatHistory.add(ToolExecutionResultMessage(request.id(), toolName, toolResult))
} }
toolIterations++
response = currentModel.generate(chatHistory, toolSpecs) response = currentModel.generate(chatHistory, toolSpecs)
aiMessage = response.content() aiMessage = response.content()
chatHistory.add(aiMessage) chatHistory.add(aiMessage)
} }
// 2. Save final AI message to DB
chatDao?.insertMessage(ChatMessageEntity(threadId = currentThreadId, text = aiMessage.text(), isUser = false)) chatDao?.insertMessage(ChatMessageEntity(threadId = currentThreadId, text = aiMessage.text(), isUser = false))
return aiMessage.text() return aiMessage.text()
} }
} }

View File

@@ -35,7 +35,7 @@ class MediaPipeAdapter(
// THE PUSH: Aggressively demand the Adreno GPU // THE PUSH: Aggressively demand the Adreno GPU
val gpuOptions = LlmInference.LlmInferenceOptions.builder() val gpuOptions = LlmInference.LlmInferenceOptions.builder()
.setModelPath(modelPath) .setModelPath(modelPath)
.setMaxTokens(4096) .setMaxTokens(1200)
.setPreferredBackend(LlmInference.Backend.GPU) .setPreferredBackend(LlmInference.Backend.GPU)
.build() .build()
engine = LlmInference.createFromOptions(context, gpuOptions) engine = LlmInference.createFromOptions(context, gpuOptions)
@@ -50,7 +50,7 @@ class MediaPipeAdapter(
val cpuOptions = LlmInference.LlmInferenceOptions.builder() val cpuOptions = LlmInference.LlmInferenceOptions.builder()
.setModelPath(modelPath) .setModelPath(modelPath)
.setMaxTokens(4096) .setMaxTokens(1200)
.setPreferredBackend(LlmInference.Backend.CPU) .setPreferredBackend(LlmInference.Backend.CPU)
.build() .build()
engine = LlmInference.createFromOptions(context, cpuOptions) engine = LlmInference.createFromOptions(context, cpuOptions)
@@ -105,7 +105,7 @@ class MediaPipeAdapter(
} }
} }
is ToolExecutionResultMessage -> { is ToolExecutionResultMessage -> {
promptBuilder.append("<start_of_turn>user\n[SYSTEM DATA VIA TOOL '${message.toolName()}']: ${message.text()}\nUse this real data to answer the previous question.<end_of_turn>\n") promptBuilder.append("<start_of_turn>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.<end_of_turn>\n")
} }
is AiMessage -> { is AiMessage -> {
if (message.hasToolExecutionRequests()) { if (message.hasToolExecutionRequests()) {

View File

@@ -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" downloadUrl = "https://huggingface.co/google/gemma-3n-E2B-it-litert-lm/resolve/main/gemma-3n-E2B-it-int4.litertlm"
), ),
LocalModel( LocalModel(
id = "Gemma3-1B-IT_multi-prefill-seq_q8_ekv4096", id = "Qwen2.5-1.5B-Instruct_seq128_q8_ekv1280",
name = "Gemma 3 (1B) Prefill", name = "Qwen2.5-1.5B",
sizeMb = 3390, sizeMb = 1570,
description = "A highly optimised and fine tuned model for agentic tasks and function calling.", description = "A highly optimised and fine tuned model for agentic tasks and function calling.",
fileName = "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/Gemma3-1B-IT/resolve/main/Gemma3-1B-IT_multi-prefill-seq_q8_ekv4096.task" downloadUrl = "https://huggingface.co/litert-community/Qwen2.5-1.5B-Instruct/resolve/main/Qwen2.5-1.5B-Instruct_seq128_q8_ekv1280.task"
) )
) )

View File

@@ -1,10 +1,12 @@
package net.mmanningau.alice package net.mmanningau.alice
import android.content.Context import android.content.Context
import android.net.Uri
import android.util.Log import android.util.Log
import com.chaquo.python.Python import com.chaquo.python.Python
import dev.langchain4j.agent.tool.JsonSchemaProperty import dev.langchain4j.agent.tool.JsonSchemaProperty
import dev.langchain4j.agent.tool.ToolSpecification import dev.langchain4j.agent.tool.ToolSpecification
import org.json.JSONArray
import org.json.JSONObject import org.json.JSONObject
import java.io.File import java.io.File
@@ -12,7 +14,11 @@ object SkillManager {
var skillsDirectory: File? = null var skillsDirectory: File? = null
private set private set
// *** ADDED: store context for ContentResolver access
private var appContext: Context? = null
fun initialize(context: Context) { fun initialize(context: Context) {
appContext = context.applicationContext // *** ADDED
val baseDir = context.getExternalFilesDir(null) val baseDir = context.getExternalFilesDir(null)
val skillsDir = File(baseDir, "Skills") val skillsDir = File(baseDir, "Skills")
@@ -25,7 +31,7 @@ object SkillManager {
fun updateDirectory(newPath: String) { fun updateDirectory(newPath: String) {
val newDir = File(newPath) val newDir = File(newPath)
if (!newDir.exists()) { if (!newDir.exists()) {
newDir.mkdirs() // Create it if the user typed a new path newDir.mkdirs()
} }
skillsDirectory = newDir skillsDirectory = newDir
Log.i("AliceSkills", "Skills directory updated to: ${newDir.absolutePath}") Log.i("AliceSkills", "Skills directory updated to: ${newDir.absolutePath}")
@@ -48,13 +54,12 @@ object SkillManager {
.name(name) .name(name)
.description(description) .description(description)
// Parse the expected parameters so the LLM knows what to extract
val parameters = json.optJSONObject("parameters") val parameters = json.optJSONObject("parameters")
val properties = parameters?.optJSONObject("properties") val properties = parameters?.optJSONObject("properties")
properties?.keys()?.forEach { key -> properties?.keys()?.forEach { key ->
val prop = properties.getJSONObject(key) val prop = properties.getJSONObject(key)
val type = prop.getString("type") // e.g., "string" val type = prop.getString("type")
val desc = prop.optString("description", "") val desc = prop.optString("description", "")
builder.addParameter( builder.addParameter(
@@ -85,23 +90,60 @@ object SkillManager {
val py = Python.getInstance() val py = Python.getInstance()
val builtins = py.builtins 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") val globals = py.getModule("builtins").callAttr("dict")
// Execute the raw script text
builtins.callAttr("exec", scriptFile.readText(), globals) builtins.callAttr("exec", scriptFile.readText(), globals)
// Find the 'execute' function we mandated in our python script
val executeFunc = globals.callAttr("get", "execute") 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! // First call to Python
executeFunc.call(argumentsJson).toString() 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) { } catch (e: Exception) {
Log.e("AliceSkills", "Execution failed for $toolName", e) Log.e("AliceSkills", "Execution failed for $toolName", e)
"Error executing skill: ${e.message}" "Error executing skill: ${e.message}"
} }
} }
} }