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:
@@ -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"
|
||||||
|
|||||||
@@ -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()
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
@@ -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()) {
|
||||||
|
|||||||
@@ -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"
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -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}"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user