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
+}