diff --git a/.gitignore b/.gitignore
index 9db9486cd3..660991a405 100644
--- a/.gitignore
+++ b/.gitignore
@@ -17,6 +17,7 @@
# Java class files
*.class
+**/.cxx
# Generated files
bin/
diff --git a/apps/Android/MnnLlmChat/README.md b/apps/Android/MnnLlmChat/README.md
index 7fb4339a3f..a0d2406889 100644
--- a/apps/Android/MnnLlmChat/README.md
+++ b/apps/Android/MnnLlmChat/README.md
@@ -35,6 +35,34 @@ This is our full multimodal language model (LLM) Android app
!!!warning!!! This version has been tested exclusively on the OnePlus 13 and Xiaomi 14 Ultra, Due to the demanding performance requirements of large language models (LLMs), many budget or low-spec devices may experience issues such as slow inference speeds, application instability, or even failure to run entirely. and its stability on other devices cannot be guaranteed. If you encounter any issues, please feel free to open an issue for assistance.
+## Agentic Capability
+
+The Android app includes an optional session-level Agent mode inspired by the ActMe project:
+
++ 核心技术来源 / Core technical source: `https://github.com/huangzhengxiang/ActMe.git`
++ Normal chat and Agent chat are different conversation modes. The mode is selected when a conversation is created and is persisted with that session.
++ Existing history created before this feature is treated as normal chat.
++ Agent mode runs a lightweight local agentic loop: model planning, `system_calls` parsing, visible tool execution, tool observations, and final answer generation.
++ Current MnnLlmChat tool scope is adapted from ActMe but excludes ADB: `get_current_time`, `web_search`, `browser_url`, and `python_exec`.
++ Built-in Python includes Excel helpers plus `numpy`, `pandas`, and `openpyxl` for local data analysis.
++ Agent mode persists lightweight Memory and Skill updates in the MnnLlmChat chat database and injects them into future Agent prompts.
++ Local ChatActivity conversations rely on the live native MNN session for KV/prompt-cache reuse. Each turn submits only new input; stored chat history is for UI display. Reloading an old session does not currently restore a persisted prompt cache.
+
+
+
+
+
+Design notes:
+
++ [Agentic loop design](./docs/agentic_design.md)
++ [Built-in browser design](./docs/builtin_browser_design.md)
++ [Built-in Python design](./docs/builtin_python_design.md)
++ [Skill and memory design](./docs/skill_memory_design.md)
++ [Development notes](./docs/development_notes.md)
++ [Iteration roadmap](./docs/iteration_roadmap.md)
+
+ADB control is intentionally not included in this MnnLlmChat port.
+
# Development
+ Prepare
diff --git a/apps/Android/MnnLlmChat/app/.gitignore b/apps/Android/MnnLlmChat/app/.gitignore
index 2170eb1a55..1eb1e71d07 100644
--- a/apps/Android/MnnLlmChat/app/.gitignore
+++ b/apps/Android/MnnLlmChat/app/.gitignore
@@ -1,4 +1,6 @@
/build
+/.cxx
+/standard
google-services.json
src/standard/google-services.json
diff --git a/apps/Android/MnnLlmChat/app/build.gradle b/apps/Android/MnnLlmChat/app/build.gradle
index d6a923a292..6b4b6e0ff4 100644
--- a/apps/Android/MnnLlmChat/app/build.gradle
+++ b/apps/Android/MnnLlmChat/app/build.gradle
@@ -2,6 +2,7 @@ plugins {
id 'com.android.application'
id 'org.jetbrains.kotlin.android'
id 'org.jetbrains.kotlin.plugin.serialization' version '2.1.21'
+ id 'com.chaquo.python'
}
def enableFirebase = (project.findProperty("ENABLE_FIREBASE")?.toString()?.toBoolean() ?: false)
@@ -127,7 +128,7 @@ android {
}
}
}
-
+
productFlavors {
standard {
dimension "store"
@@ -138,6 +139,11 @@ android {
buildConfigField "boolean", "IS_GOOGLE_PLAY_BUILD", "true"
versionNameSuffix ".gp"
}
+ agent {
+ dimension "store"
+ buildConfigField "boolean", "IS_GOOGLE_PLAY_BUILD", "false"
+ versionNameSuffix ".agent"
+ }
}
@@ -183,6 +189,21 @@ android {
}
}
+chaquopy {
+ defaultConfig {
+ version "3.11"
+ }
+ productFlavors {
+ getByName("agent") {
+ pip {
+ install "openpyxl==3.1.5"
+ install "numpy"
+ install "pandas"
+ }
+ }
+ }
+}
+
dependencies {
// https://developer.android.com/jetpack/androidx/releases/compose
diff --git a/apps/Android/MnnLlmChat/app/src/main/AndroidManifest.xml b/apps/Android/MnnLlmChat/app/src/main/AndroidManifest.xml
index a58352a418..5ac3a05d62 100644
--- a/apps/Android/MnnLlmChat/app/src/main/AndroidManifest.xml
+++ b/apps/Android/MnnLlmChat/app/src/main/AndroidManifest.xml
@@ -46,6 +46,7 @@
android:name="com.alibaba.mnnllm.android.chat.ChatActivity"
android:configChanges="orientation|screenSize"
android:exported="true"
+ android:screenOrientation="portrait"
android:windowSoftInputMode="adjustResize">
@@ -75,7 +76,8 @@
+ android:exported="true"
+ android:screenOrientation="portrait">
@@ -86,7 +88,8 @@
android:name=".mainsettings.MainSettingsActivity"
android:configChanges="orientation|screenSize"
android:exported="true"
- android:label="@string/settings">
+ android:label="@string/settings"
+ android:screenOrientation="portrait">
@@ -97,7 +100,8 @@
android:name=".mainsettings.StorageManagementActivity"
android:configChanges="orientation|screenSize"
android:exported="false"
- android:label="@string/storage_management" />
+ android:label="@string/storage_management"
+ android:screenOrientation="portrait" />
diff --git a/apps/Android/MnnLlmChat/app/src/main/cpp/llm_mnn_jni.cpp b/apps/Android/MnnLlmChat/app/src/main/cpp/llm_mnn_jni.cpp
index 2bcbbd5ad2..2a0dee5ef2 100644
--- a/apps/Android/MnnLlmChat/app/src/main/cpp/llm_mnn_jni.cpp
+++ b/apps/Android/MnnLlmChat/app/src/main/cpp/llm_mnn_jni.cpp
@@ -167,6 +167,7 @@ JNIEXPORT jobject JNICALL Java_com_alibaba_mnnllm_android_llm_LlmSession_submitN
if (!onProgressMethod) {
MNN_DEBUG("ProgressListener onProgress method not found.");
}
+ int64_t input_len = llm->CountInputTokens(input_str);
auto *context = llm->Response(input_str, [&, progressListener, onProgressMethod](
const std::string &response, bool is_eop) {
if (progressListener && onProgressMethod) {
@@ -185,6 +186,16 @@ JNIEXPORT jobject JNICALL Java_com_alibaba_mnnllm_android_llm_LlmSession_submitN
int64_t audio_time = 0;
int64_t prefill_time = 0;
int64_t decode_time = 0;
+ if (context == nullptr) {
+ env->ReleaseStringUTFChars(inputStr, input_str);
+ jclass hashMapClass = env->FindClass("java/util/HashMap");
+ jmethodID hashMapInit = env->GetMethodID(hashMapClass, "", "()V");
+ jmethodID putMethod =
+ env->GetMethodID(hashMapClass, "put", "(Ljava/lang/Object;Ljava/lang/Object;)Ljava/lang/Object;");
+ jobject hashMap = env->NewObject(hashMapClass, hashMapInit);
+ env->CallObjectMethod(hashMap, putMethod, env->NewStringUTF("error"), env->NewStringUTF("Generation failed"));
+ return hashMap;
+ }
prompt_len += context->prompt_len;
decode_len += context->gen_seq_len;
vision_time += context->vision_us;
@@ -198,6 +209,10 @@ JNIEXPORT jobject JNICALL Java_com_alibaba_mnnllm_android_llm_LlmSession_submitN
jobject hashMap = env->NewObject(hashMapClass, hashMapInit);
// Add metrics to the HashMap
+ env->CallObjectMethod(hashMap, putMethod, env->NewStringUTF("input_len"),
+ env->NewObject(env->FindClass("java/lang/Long"),
+ env->GetMethodID(env->FindClass("java/lang/Long"), "", "(J)V"),
+ input_len));
env->CallObjectMethod(hashMap, putMethod, env->NewStringUTF("prompt_len"),
env->NewObject(env->FindClass("java/lang/Long"),
env->GetMethodID(env->FindClass("java/lang/Long"),
@@ -222,6 +237,7 @@ JNIEXPORT jobject JNICALL Java_com_alibaba_mnnllm_android_llm_LlmSession_submitN
env->NewObject(env->FindClass("java/lang/Long"),
env->GetMethodID(env->FindClass("java/lang/Long"),
"", "(J)V"), decode_time));
+ env->ReleaseStringUTFChars(inputStr, input_str);
return hashMap;
}
diff --git a/apps/Android/MnnLlmChat/app/src/main/cpp/llm_session.cpp b/apps/Android/MnnLlmChat/app/src/main/cpp/llm_session.cpp
index 0638f1733c..7677f06a8c 100644
--- a/apps/Android/MnnLlmChat/app/src/main/cpp/llm_session.cpp
+++ b/apps/Android/MnnLlmChat/app/src/main/cpp/llm_session.cpp
@@ -289,16 +289,22 @@ const MNN::Transformer::LlmContext * LlmSession::Response(const std::string &pro
history_.emplace_back("user", getUserString(prompt.c_str(), false, is_r1_));
#endif
MNN_DEBUG("submitNative history count %zu", history_.size());
-
- // Generate full prompt string using Prompt::applyTemplate
+
+ prompt_string_for_debug.clear();
+ response_string_for_debug.clear();
+
+ // Generate a logical prompt preview for debugging. The engine may still use
+ // prompt cache internally and prefill only the delta; see the PERF log below.
std::string full_prompt_text;
for (auto & it : history_) {
full_prompt_text += it.second;
- prompt_string_for_debug += it.second;
}
-
- MNN_DEBUG("submitNative prompt_string_for_debug count %s max_new_tokens_:%d", prompt_string_for_debug.c_str(), max_new_tokens_);
-
+ prompt_string_for_debug = full_prompt_text;
+
+ std::string prompt_preview = full_prompt_text.substr(0, 2048);
+ MNN_DEBUG("submitNative logical_prompt chars=%zu preview=%s max_new_tokens_:%d", full_prompt_text.size(),
+ prompt_preview.c_str(), max_new_tokens_);
+
// Check for multimodal content in the full prompt
auto multimodal_result = processMultimodalPrompt(full_prompt_text);
restoreAndroidSteppingStatusIfNeeded(llm_);
@@ -361,10 +367,12 @@ const MNN::Transformer::LlmContext * LlmSession::Response(const std::string &pro
float decode_s = context->decode_us / 1e6f;
float prefill_tps = (prefill_s > 0) ? context->prompt_len / prefill_s : 0;
float decode_tps = (decode_s > 0) ? context->gen_seq_len / decode_s : 0;
- MNN_DEBUG("PERF | prefill: %d tok in %.2fs (%.1f t/s) | decode: %d tok in %.2fs (%.1f t/s) | history: %zu msgs",
- context->prompt_len, prefill_s, prefill_tps,
- context->gen_seq_len, decode_s, decode_tps,
- history_.size());
+ bool prompt_cache_enabled = current_config_.value("prompt_cache", false);
+ MNN_DEBUG(
+ "PERF | prompt_cache: %d | actual_prefill: %d tok in %.2fs (%.1f t/s) | decode: %d tok in %.2fs (%.1f t/s) | "
+ "history: %zu msgs",
+ prompt_cache_enabled ? 1 : 0, context->prompt_len, prefill_s, prefill_tps, context->gen_seq_len, decode_s,
+ decode_tps, history_.size());
return context;
}
@@ -403,6 +411,13 @@ void LlmSession::SetMaxNewTokens(int i) {
max_new_tokens_ = i;
}
+int LlmSession::CountInputTokens(const std::string& prompt) const {
+ if (llm_ == nullptr) {
+ return 0;
+ }
+ return static_cast(llm_->tokenizer_encode(prompt).size());
+}
+
void LlmSession::setSystemPrompt(std::string system_prompt) {
system_prompt_= std::move(system_prompt);
if (history_.size() > 1) {
diff --git a/apps/Android/MnnLlmChat/app/src/main/cpp/llm_session.h b/apps/Android/MnnLlmChat/app/src/main/cpp/llm_session.h
index d87bebc065..47c02833c1 100644
--- a/apps/Android/MnnLlmChat/app/src/main/cpp/llm_session.h
+++ b/apps/Android/MnnLlmChat/app/src/main/cpp/llm_session.h
@@ -39,6 +39,8 @@ class LlmSession {
Response(const std::string &prompt, const std::function &on_progress);
void SetMaxNewTokens(int i);
+ int CountInputTokens(const std::string& prompt) const;
+
void setSystemPrompt(std::string system_prompt);
void SetAssistantPrompt(const std::string& assistant_prompt);
diff --git a/apps/Android/MnnLlmChat/app/src/main/java/com/alibaba/mnnllm/android/MnnLlmApplication.kt b/apps/Android/MnnLlmChat/app/src/main/java/com/alibaba/mnnllm/android/MnnLlmApplication.kt
index 5694de8633..6493edcb4b 100644
--- a/apps/Android/MnnLlmChat/app/src/main/java/com/alibaba/mnnllm/android/MnnLlmApplication.kt
+++ b/apps/Android/MnnLlmChat/app/src/main/java/com/alibaba/mnnllm/android/MnnLlmApplication.kt
@@ -6,6 +6,7 @@ import android.app.Application
import com.alibaba.mls.api.ApplicationProvider
import com.alibaba.mls.api.download.ModelDownloadManager
import com.alibaba.mnnllm.android.update.UpdateChecker
+import com.alibaba.mnnllm.android.agent.AgenticPythonEngine
import com.alibaba.mnnllm.android.utils.CrashUtil
import com.alibaba.mnnllm.android.utils.CurrentActivityTracker
import com.alibaba.mnnllm.android.utils.TimberConfig
@@ -21,6 +22,7 @@ class MnnLlmApplication : Application() {
override fun onCreate() {
super.onCreate()
ApplicationProvider.set(this)
+ AgenticPythonEngine.initialize(this)
UpdateChecker.registerDownloadReceiver(applicationContext)
CrashUtil.init(this)
instance = this
diff --git a/apps/Android/MnnLlmChat/app/src/main/java/com/alibaba/mnnllm/android/agent/AgentWorkspaceFileBrowser.kt b/apps/Android/MnnLlmChat/app/src/main/java/com/alibaba/mnnllm/android/agent/AgentWorkspaceFileBrowser.kt
new file mode 100644
index 0000000000..99af74cf70
--- /dev/null
+++ b/apps/Android/MnnLlmChat/app/src/main/java/com/alibaba/mnnllm/android/agent/AgentWorkspaceFileBrowser.kt
@@ -0,0 +1,222 @@
+package com.alibaba.mnnllm.android.agent
+
+import android.content.Context
+import android.content.Intent
+import android.view.ViewGroup
+import android.widget.LinearLayout
+import android.widget.ScrollView
+import android.widget.TextView
+import android.widget.Toast
+import androidx.core.content.FileProvider
+import com.alibaba.mnnllm.android.R
+import com.google.android.material.dialog.MaterialAlertDialogBuilder
+import java.io.File
+import java.util.Locale
+
+object AgentWorkspaceFileBrowser {
+ fun show(context: Context) {
+ val workspace = AgenticPythonEngine.workspaceDir(context)
+ showDirectory(context, workspace, workspace)
+ }
+
+ private fun showDirectory(context: Context, workspace: File, directory: File) {
+ val children = listDirectoryChildren(directory)
+ if (children.isEmpty()) {
+ MaterialAlertDialogBuilder(context)
+ .setTitle(directoryTitle(workspace, directory))
+ .setMessage(R.string.workspace_files_empty)
+ .setPositiveButton(android.R.string.ok, null)
+ .show()
+ return
+ }
+
+ val list = LinearLayout(context).apply {
+ orientation = LinearLayout.VERTICAL
+ setPadding(24, 8, 24, 8)
+ }
+ if (directory.canonicalFile != workspace.canonicalFile) {
+ list.addView(createParentRow(context, workspace, directory.parentFile ?: workspace))
+ }
+ children.forEach { file ->
+ list.addView(createFileRow(context, workspace, file))
+ }
+
+ val scroll = ScrollView(context).apply {
+ addView(
+ list,
+ ViewGroup.LayoutParams(
+ ViewGroup.LayoutParams.MATCH_PARENT,
+ ViewGroup.LayoutParams.WRAP_CONTENT
+ )
+ )
+ }
+
+ MaterialAlertDialogBuilder(context)
+ .setTitle(directoryTitle(workspace, directory))
+ .setView(scroll)
+ .setNegativeButton(android.R.string.cancel, null)
+ .show()
+ }
+
+ private fun createFileRow(context: Context, workspace: File, file: File): TextView {
+ val relativePath = runCatching {
+ file.relativeTo(workspace).path.replace("\\", "/")
+ }.getOrElse { file.name }
+ return TextView(context).apply {
+ text = buildString {
+ append(if (file.isDirectory) "[Folder] " else "[File] ")
+ append(relativePath)
+ append("\n")
+ append(if (file.isDirectory) "${file.listFiles()?.size ?: 0} items" else formatFileSize(file.length()))
+ }
+ textSize = 14f
+ setPadding(0, 14, 0, 14)
+ layoutParams = LinearLayout.LayoutParams(
+ ViewGroup.LayoutParams.MATCH_PARENT,
+ ViewGroup.LayoutParams.WRAP_CONTENT
+ )
+ setOnClickListener {
+ if (file.isDirectory) {
+ showDirectory(context, workspace, file)
+ } else {
+ showFile(context, workspace, file)
+ }
+ }
+ }
+ }
+
+ private fun createParentRow(context: Context, workspace: File, parent: File): TextView {
+ return TextView(context).apply {
+ text = "[Folder] .."
+ textSize = 14f
+ setPadding(0, 14, 0, 14)
+ layoutParams = LinearLayout.LayoutParams(
+ ViewGroup.LayoutParams.MATCH_PARENT,
+ ViewGroup.LayoutParams.WRAP_CONTENT
+ )
+ setOnClickListener { showDirectory(context, workspace, parent) }
+ }
+ }
+
+ private fun listDirectoryChildren(directory: File): List {
+ if (!directory.exists() || !directory.isDirectory) return emptyList()
+ return directory.listFiles().orEmpty()
+ .sortedWith(compareByDescending { it.isDirectory }.thenBy { it.name.lowercase(Locale.US) })
+ }
+
+ private fun directoryTitle(workspace: File, directory: File): String {
+ val relativePath = runCatching {
+ directory.relativeTo(workspace).path.replace("\\", "/")
+ }.getOrElse { "" }
+ return if (relativePath.isBlank() || relativePath == ".") {
+ "Workspace files"
+ } else {
+ relativePath
+ }
+ }
+
+ private fun showFile(context: Context, workspace: File, file: File) {
+ val relativePath = runCatching {
+ file.relativeTo(workspace).path.replace("\\", "/")
+ }.getOrElse { file.name }
+ val message = if (canPreviewText(file.name)) {
+ readTextPreview(file)
+ } else {
+ "No built-in preview for this file type.\n\nPath: $relativePath\nSize: ${formatFileSize(file.length())}"
+ }
+ val text = TextView(context).apply {
+ this.text = message
+ textSize = 13f
+ setPadding(24, 8, 24, 8)
+ }
+ val scroll = ScrollView(context).apply {
+ addView(
+ text,
+ ViewGroup.LayoutParams(
+ ViewGroup.LayoutParams.MATCH_PARENT,
+ ViewGroup.LayoutParams.WRAP_CONTENT
+ )
+ )
+ }
+ MaterialAlertDialogBuilder(context)
+ .setTitle(relativePath)
+ .setView(scroll)
+ .setNegativeButton(android.R.string.cancel, null)
+ .setPositiveButton("Open with app") { _, _ -> openFileWithExternalApp(context, file) }
+ .show()
+ }
+
+ private fun openFileWithExternalApp(context: Context, file: File) {
+ try {
+ val uri = FileProvider.getUriForFile(
+ context,
+ context.packageName + ".fileprovider",
+ file
+ )
+ val intent = Intent(Intent.ACTION_VIEW).apply {
+ setDataAndType(uri, guessMimeType(file.name))
+ addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
+ }
+ context.startActivity(Intent.createChooser(intent, file.name))
+ } catch (e: Exception) {
+ Toast.makeText(context, R.string.workspace_file_open_failed, Toast.LENGTH_SHORT).show()
+ }
+ }
+
+ private fun canPreviewText(name: String): Boolean {
+ val lower = name.lowercase(Locale.US)
+ return lower.endsWith(".txt") ||
+ lower.endsWith(".md") ||
+ lower.endsWith(".csv") ||
+ lower.endsWith(".json") ||
+ lower.endsWith(".py") ||
+ lower.endsWith(".log") ||
+ lower.endsWith(".xml") ||
+ lower.endsWith(".html") ||
+ lower.endsWith(".htm")
+ }
+
+ private fun readTextPreview(file: File): String {
+ return try {
+ val limit = 128 * 1024
+ val buffer = ByteArray(limit + 1)
+ val count = file.inputStream().use { input ->
+ input.read(buffer)
+ }
+ if (count <= 0) {
+ return ""
+ }
+ val truncated = count > limit
+ val text = buffer.copyOfRange(0, minOf(count, limit)).toString(Charsets.UTF_8)
+ if (truncated) {
+ "$text\n\n... truncated ..."
+ } else {
+ text
+ }
+ } catch (_: Exception) {
+ "Failed to read this file."
+ }
+ }
+
+ private fun guessMimeType(name: String): String {
+ val lower = name.lowercase(Locale.US)
+ return when {
+ lower.endsWith(".xlsx") -> "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"
+ lower.endsWith(".xls") -> "application/vnd.ms-excel"
+ lower.endsWith(".csv") -> "text/csv"
+ lower.endsWith(".pdf") -> "application/pdf"
+ lower.endsWith(".txt") -> "text/plain"
+ lower.endsWith(".json") -> "application/json"
+ lower.endsWith(".png") -> "image/png"
+ lower.endsWith(".jpg") || lower.endsWith(".jpeg") -> "image/jpeg"
+ else -> "*/*"
+ }
+ }
+
+ private fun formatFileSize(bytes: Long): String {
+ if (bytes < 1024) return "$bytes B"
+ val kb = bytes / 1024.0
+ if (kb < 1024) return String.format(Locale.US, "%.1f KB", kb)
+ return String.format(Locale.US, "%.1f MB", kb / 1024.0)
+ }
+}
diff --git a/apps/Android/MnnLlmChat/app/src/main/java/com/alibaba/mnnllm/android/agent/AgenticOutputParser.kt b/apps/Android/MnnLlmChat/app/src/main/java/com/alibaba/mnnllm/android/agent/AgenticOutputParser.kt
new file mode 100644
index 0000000000..d84f4187c5
--- /dev/null
+++ b/apps/Android/MnnLlmChat/app/src/main/java/com/alibaba/mnnllm/android/agent/AgenticOutputParser.kt
@@ -0,0 +1,309 @@
+// Copyright (c) 2024 Alibaba Group Holding Limited All rights reserved.
+
+package com.alibaba.mnnllm.android.agent
+
+import com.google.gson.Gson
+import com.google.gson.JsonArray
+import com.google.gson.JsonElement
+import com.google.gson.JsonObject
+import com.google.gson.JsonParser
+import com.google.gson.JsonSyntaxException
+
+object AgenticOutputParser {
+ private val gson = Gson()
+ private val fencedJsonRegex = Regex("```(?:json)?\\s*([\\s\\S]*?)```", RegexOption.IGNORE_CASE)
+ private val pythonExecLooseRegex = Regex(
+ """"type"\s*:\s*"python_exec"[\s\S]*?"code"\s*:\s*"((?:\\.|[^"\\])*)"""",
+ RegexOption.IGNORE_CASE
+ )
+ private val toolTypes = setOf(
+ "get_current_time",
+ "web_search",
+ "browser_url",
+ "python_exec",
+ "run_python",
+ "python"
+ )
+
+ fun parse(text: String): AgenticResponse? {
+ for (candidate in candidates(text)) {
+ val response = parseCandidate(candidate) ?: parseLooseToolCandidate(candidate) ?: continue
+ if (response.systemCalls.isNullOrEmpty() && !response.reply.isNullOrBlank()) {
+ val nested = parseNestedReply(response.reply)
+ if (nested != null) {
+ return nested
+ }
+ }
+ if (response.reply != null ||
+ !response.systemCalls.isNullOrEmpty() ||
+ !response.memoryUpdates.isNullOrEmpty() ||
+ !response.skillUpdates.isNullOrEmpty()
+ ) {
+ return response
+ }
+ }
+ return null
+ }
+
+ fun extractToolCalls(text: String): List {
+ return parse(text)?.systemCalls.orEmpty()
+ }
+
+ private fun candidates(text: String): List {
+ val result = mutableListOf()
+ result.add(text.trim())
+ fencedJsonRegex.findAll(text).forEach { match ->
+ result.add(match.groupValues[1].trim())
+ }
+ extractFirstJsonObject(text)?.let { result.add(it) }
+ return result.distinct().filter { it.isNotBlank() }
+ }
+
+ private fun parseCandidate(candidate: String): AgenticResponse? {
+ return try {
+ val element = JsonParser.parseString(candidate)
+ when {
+ element.isJsonArray -> parseToolArray(element.asJsonArray)
+ element.isJsonObject -> {
+ val jsonObject = element.asJsonObject
+ parseSingleToolObject(jsonObject)
+ ?: parseResponseObject(jsonObject)
+ }
+ else -> null
+ }
+ } catch (_: JsonSyntaxException) {
+ null
+ } catch (_: IllegalStateException) {
+ null
+ }
+ }
+
+ private fun parseLooseToolCandidate(candidate: String): AgenticResponse? {
+ val match = pythonExecLooseRegex.find(candidate) ?: return null
+ val code = unescapeJsonString(match.groupValues[1])
+ if (code.isBlank()) return null
+ val input = Regex(""""input"\s*:\s*"((?:\\.|[^"\\])*)"""")
+ .find(candidate)
+ ?.groupValues
+ ?.getOrNull(1)
+ ?.let(::unescapeJsonString)
+ .orEmpty()
+ val timeoutMs = Regex(""""timeout_ms"\s*:\s*(\d+)""")
+ .find(candidate)
+ ?.groupValues
+ ?.getOrNull(1)
+ ?.toLongOrNull()
+ val outputFiles = Regex(""""(?:output_files|generated_files|expected_outputs|files)"\s*:\s*\[([\s\S]*?)]""")
+ .find(candidate)
+ ?.groupValues
+ ?.getOrNull(1)
+ ?.let { raw ->
+ Regex(""""((?:\\.|[^"\\])*)"""").findAll(raw)
+ .map { unescapeJsonString(it.groupValues[1]) }
+ .filter { it.isNotBlank() }
+ .toList()
+ }
+ return AgenticResponse(
+ reply = "",
+ systemCalls = listOf(
+ AgentSystemCall(
+ type = "python_exec",
+ code = code,
+ input = input,
+ timeoutMs = timeoutMs,
+ outputFiles = outputFiles
+ )
+ )
+ )
+ }
+
+ private fun unescapeJsonString(value: String): String {
+ return runCatching {
+ gson.fromJson("\"$value\"", String::class.java)
+ }.getOrElse {
+ value.replace("\\n", "\n")
+ .replace("\\t", "\t")
+ .replace("\\\"", "\"")
+ .replace("\\\\", "\\")
+ }
+ }
+
+ private fun parseResponseObject(jsonObject: JsonObject): AgenticResponse? {
+ return AgenticResponse(
+ reply = jsonObject.stringValue("reply"),
+ memoryUpdates = parseMemoryUpdates(jsonObject.get("memory_updates")),
+ skillUpdates = parseSkillUpdates(jsonObject.get("skill_updates")),
+ systemCalls = parseSystemCalls(jsonObject.get("system_calls"))
+ )
+ }
+
+ private fun parseSingleToolObject(jsonObject: JsonObject): AgenticResponse? {
+ if (jsonObject.has("system_calls") ||
+ jsonObject.has("reply") ||
+ jsonObject.has("memory_updates") ||
+ jsonObject.has("skill_updates")
+ ) {
+ return null
+ }
+
+ val explicitType = jsonObject.get("type")?.takeIf { it.isJsonPrimitive }?.asString?.trim()
+ val inferredType = explicitType?.takeIf { it in toolTypes } ?: inferToolType(jsonObject)
+ if (inferredType.isNullOrBlank()) {
+ return null
+ }
+
+ val normalized = jsonObject.deepCopy()
+ normalized.addProperty("type", inferredType)
+ val call = gson.fromJson(normalized, AgentSystemCall::class.java)
+ return AgenticResponse(reply = "", systemCalls = listOf(call))
+ }
+
+ private fun parseToolArray(jsonArray: JsonArray): AgenticResponse? {
+ val calls = parseToolCallsFromArray(jsonArray)
+ return calls.takeIf { it.isNotEmpty() }?.let {
+ AgenticResponse(reply = "", systemCalls = it)
+ }
+ }
+
+ private fun parseSystemCalls(element: JsonElement?): List? {
+ if (element == null || element.isJsonNull) return null
+ return when {
+ element.isJsonArray -> parseToolCallsFromArray(element.asJsonArray)
+ element.isJsonObject -> parseSingleToolObject(element.asJsonObject)?.systemCalls.orEmpty()
+ element.isJsonPrimitive && element.asJsonPrimitive.isString ->
+ parse(element.asString)?.systemCalls.orEmpty()
+ else -> emptyList()
+ }.takeIf { it.isNotEmpty() }
+ }
+
+ private fun parseToolCallsFromArray(jsonArray: JsonArray): List {
+ return jsonArray.flatMap { element ->
+ when {
+ element.isJsonObject ->
+ parseSingleToolObject(element.asJsonObject)?.systemCalls.orEmpty()
+ element.isJsonArray ->
+ parseToolCallsFromArray(element.asJsonArray)
+ element.isJsonPrimitive && element.asJsonPrimitive.isString ->
+ parse(element.asString)?.systemCalls.orEmpty()
+ else ->
+ emptyList()
+ }
+ }
+ }
+
+ private fun parseMemoryUpdates(element: JsonElement?): List? {
+ val array = element?.takeIf { it.isJsonArray }?.asJsonArray ?: return null
+ return array.mapNotNull { item ->
+ when {
+ item.isJsonPrimitive && item.asJsonPrimitive.isString ->
+ AgentMemoryUpdate(category = "general", content = item.asString)
+ item.isJsonObject -> {
+ val obj = item.asJsonObject
+ AgentMemoryUpdate(
+ category = obj.stringValue("category") ?: "general",
+ content = obj.stringValue("content") ?: obj.stringValue("text")
+ )
+ }
+ else -> null
+ }
+ }.takeIf { it.isNotEmpty() }
+ }
+
+ private fun parseSkillUpdates(element: JsonElement?): List? {
+ val array = element?.takeIf { it.isJsonArray }?.asJsonArray ?: return null
+ return array.mapNotNull { item ->
+ when {
+ item.isJsonPrimitive && item.asJsonPrimitive.isString ->
+ AgentSkillUpdate(description = item.asString)
+ item.isJsonObject -> {
+ val obj = item.asJsonObject
+ AgentSkillUpdate(
+ name = obj.stringValue("name"),
+ description = obj.stringValue("description") ?: obj.stringValue("content"),
+ triggerKeywords = parseStringList(obj.get("trigger_keywords")),
+ actionTemplate = obj.stringValue("action_template")
+ )
+ }
+ else -> null
+ }
+ }.takeIf { it.isNotEmpty() }
+ }
+
+ private fun inferToolType(jsonObject: JsonObject): String? {
+ return when {
+ jsonObject.has("code") -> "python_exec"
+ jsonObject.has("url") -> "browser_url"
+ jsonObject.has("query") -> "web_search"
+ else -> null
+ }
+ }
+
+ private fun parseStringList(element: JsonElement?): List? {
+ if (element == null || element.isJsonNull) return null
+ return when {
+ element.isJsonArray -> element.asJsonArray.mapNotNull {
+ it.takeIf { value -> value.isJsonPrimitive }?.asString
+ }
+ element.isJsonPrimitive && element.asJsonPrimitive.isString ->
+ element.asString.split(',', ';').map { it.trim() }.filter { it.isNotBlank() }
+ else -> null
+ }
+ }
+
+ private fun JsonObject.stringValue(name: String): String? {
+ val element = get(name) ?: return null
+ if (!element.isJsonPrimitive || !element.asJsonPrimitive.isString) return null
+ return element.asString.takeIf { it.isNotBlank() }
+ }
+
+ private fun parseNestedReply(reply: String): AgenticResponse? {
+ for (candidate in candidates(reply)) {
+ val nested = parseCandidate(candidate) ?: continue
+ if (nested.reply != null ||
+ !nested.systemCalls.isNullOrEmpty() ||
+ !nested.memoryUpdates.isNullOrEmpty() ||
+ !nested.skillUpdates.isNullOrEmpty()
+ ) {
+ return nested
+ }
+ }
+ return null
+ }
+
+ private fun extractFirstJsonObject(text: String): String? {
+ val start = text.indexOf('{')
+ if (start < 0) return null
+
+ var depth = 0
+ var inString = false
+ var escaping = false
+
+ for (i in start until text.length) {
+ val ch = text[i]
+ if (escaping) {
+ escaping = false
+ continue
+ }
+ if (ch == '\\' && inString) {
+ escaping = true
+ continue
+ }
+ if (ch == '"') {
+ inString = !inString
+ continue
+ }
+ if (inString) continue
+
+ when (ch) {
+ '{' -> depth++
+ '}' -> {
+ depth--
+ if (depth == 0) {
+ return text.substring(start, i + 1)
+ }
+ }
+ }
+ }
+ return null
+ }
+}
diff --git a/apps/Android/MnnLlmChat/app/src/main/java/com/alibaba/mnnllm/android/agent/AgenticPrompts.kt b/apps/Android/MnnLlmChat/app/src/main/java/com/alibaba/mnnllm/android/agent/AgenticPrompts.kt
new file mode 100644
index 0000000000..e305753919
--- /dev/null
+++ b/apps/Android/MnnLlmChat/app/src/main/java/com/alibaba/mnnllm/android/agent/AgenticPrompts.kt
@@ -0,0 +1,136 @@
+// Copyright (c) 2024 Alibaba Group Holding Limited All rights reserved.
+
+package com.alibaba.mnnllm.android.agent
+
+data class AgentToolSpec(
+ val name: String,
+ val description: String,
+ val inputSchema: String
+)
+
+data class AgentLoopBudget(
+ val maxPasses: Int,
+ val maxToolCalls: Int,
+ val maxBrowserCalls: Int,
+ val maxPythonCalls: Int
+)
+
+object AgenticPrompts {
+ val defaultLoopBudget = AgentLoopBudget(
+ maxPasses = 6,
+ maxToolCalls = 12,
+ maxBrowserCalls = 8,
+ maxPythonCalls = 6
+ )
+
+ val supportedTools = listOf(
+ AgentToolSpec(
+ name = "get_current_time",
+ description = "Return the current local date, time, timezone, weekday, and epoch milliseconds.",
+ inputSchema = """{"type":"get_current_time"}"""
+ ),
+ AgentToolSpec(
+ name = "browser_url",
+ description = "Open or read a URL through the in-app browser layer and return page text, title, url, and status.",
+ inputSchema = """{"url":"https://example.com","goal":"what to verify on this page"}"""
+ ),
+ AgentToolSpec(
+ name = "web_search",
+ description = "Search the web and return candidate result titles, snippets, and URLs for follow-up browser_url calls.",
+ inputSchema = """{"type":"web_search","query":"search query"}"""
+ ),
+ AgentToolSpec(
+ name = "python_exec",
+ description = "Run bounded Python 3.11 code in the app sandbox for calculation, data analysis, py_compile checks, script storage, and Excel read/write helpers. Available packages include numpy, pandas, and openpyxl.",
+ inputSchema = """{"type":"python_exec","code":"print(1+1)","input":"optional text or JSON","timeout_ms":15000,"output_files":[]}"""
+ )
+ )
+
+ const val outputContract: String = """
+Return either a final user reply or a JSON tool request. Tool requests must be valid JSON:
+{
+ "reply": "",
+ "memory_updates": [],
+ "skill_updates": [],
+ "system_calls": [
+ {
+ "type": "get_current_time | web_search | browser_url | python_exec",
+ "query": "for web_search",
+ "url": "for browser_url",
+ "code": "for python_exec",
+ "input": "optional input for python_exec",
+ "timeout_ms": 15000,
+ "output_files": ["workspace-relative paths of files this call creates, such as sample.xlsx"]
+ }
+ ]
+}
+
+Rules:
+- Prefer verifying fresh, factual, or high-impact claims with web_search and browser_url.
+- Use browser_url to inspect primary sources, not only search snippets.
+- Use get_current_time when exact current time matters.
+- Use python_exec for math, statistics, parsing, table-like data processing, py_compile checks, reusable scripts, and Excel work.
+- In python_exec, available packages include numpy, pandas, openpyxl, and standard-library modules such as csv, json, statistics, and collections.
+- In python_exec, available helpers include read_excel(path), write_excel(filename, sheets), save_script(name, source), load_script(name), list_scripts(), run_script(name), compile_script(name), emit(value), and set_result(value).
+- Python code must directly perform the requested action in the submitted script; do not only define functions without calling them.
+- Python relative file paths are resolved inside the app's writable agent workspace.
+- For creating simple Excel files, prefer write_excel(filename, sheets) over pandas so files are saved inside the app workspace and start quickly.
+- When python_exec creates files, fill output_files with every generated workspace-relative filename/path.
+- Files created or modified by python_exec are automatically returned to the chat as attachments.
+- Do not import network/process/filesystem control modules such as requests, socket, subprocess, os, or sys. Use browser_url/web_search for network access and the provided workspace helpers for files.
+- memory_updates are only for durable user preferences/facts. Do not store task status, generated file summaries, examples, apologies, or one-off results.
+- skill_updates are only for reusable procedures with a short name, triggers, and action_template. Do not store one-off task descriptions.
+- Keep memory/skill updates short: memory content <= 168 chars, skill action_template <= 350 chars.
+- Do not expose raw tool JSON to the user in the final answer.
+- If a tool fails, try another route when useful, then explain only the user-relevant limitation.
+- Stop requesting tools when the current evidence is enough or the app reports that the loop budget is exhausted.
+"""
+
+ fun buildIdentityMemory(modelName: String, modelId: String): String {
+ return buildString {
+ appendLine("Host: MNN Chat, local on-device MNN LLM runtime.")
+ appendLine("Model: ${modelName.ifBlank { "unknown" }} (${modelId.ifBlank { "unknown" }}).")
+ appendLine("Tools are executed by the host app after valid system_calls JSON.")
+ }.trim()
+ }
+
+ fun buildSystemPrompt(memoryBlock: String = "", skillBlock: String = ""): String {
+ return buildString {
+ appendLine("You are MNN Chat's on-device assistant.")
+ appendLine("Use host tools via valid system_calls JSON when they improve accuracy or perform requested actions.")
+ appendLine("For current web facts, search, browse, or URL inspection, request web_search/browser_url instead of saying you lack internet access.")
+ appendLine("Answer directly when tools are unnecessary.")
+ appendLine()
+ appendLine("Available tools:")
+ supportedTools.forEach { tool ->
+ appendLine("- ${tool.name}: ${tool.description}")
+ appendLine(" schema: ${tool.inputSchema}")
+ }
+ if (memoryBlock.isNotBlank()) {
+ appendLine()
+ appendLine("Relevant memory:")
+ appendLine(memoryBlock)
+ }
+ if (skillBlock.isNotBlank()) {
+ appendLine()
+ appendLine("Relevant skills:")
+ appendLine(skillBlock)
+ }
+ appendLine()
+ appendLine(outputContract.trim())
+ }
+ }
+
+ fun buildSystemPromptForModel(
+ modelName: String,
+ modelId: String,
+ memoryBlock: String = "",
+ skillBlock: String = ""
+ ): String {
+ val identityBlock = buildIdentityMemory(modelName, modelId)
+ val mergedMemory = listOf(identityBlock, memoryBlock)
+ .filter { it.isNotBlank() }
+ .joinToString(separator = "\n\n")
+ return buildSystemPrompt(memoryBlock = mergedMemory, skillBlock = skillBlock)
+ }
+}
diff --git a/apps/Android/MnnLlmChat/app/src/main/java/com/alibaba/mnnllm/android/agent/AgenticProtocol.kt b/apps/Android/MnnLlmChat/app/src/main/java/com/alibaba/mnnllm/android/agent/AgenticProtocol.kt
new file mode 100644
index 0000000000..66ce7a8273
--- /dev/null
+++ b/apps/Android/MnnLlmChat/app/src/main/java/com/alibaba/mnnllm/android/agent/AgenticProtocol.kt
@@ -0,0 +1,53 @@
+// Copyright (c) 2024 Alibaba Group Holding Limited All rights reserved.
+
+package com.alibaba.mnnllm.android.agent
+
+import com.google.gson.annotations.SerializedName
+
+data class AgenticResponse(
+ val reply: String? = null,
+ @SerializedName("memory_updates") val memoryUpdates: List? = null,
+ @SerializedName("skill_updates") val skillUpdates: List? = null,
+ @SerializedName("system_calls") val systemCalls: List? = null
+) {
+ fun hasToolCalls(): Boolean = !systemCalls.isNullOrEmpty()
+ fun visibleReply(): String = reply.orEmpty()
+}
+
+data class AgentSystemCall(
+ val type: String? = null,
+ val query: String? = null,
+ val url: String? = null,
+ val code: String? = null,
+ val input: String? = null,
+ @SerializedName("timeout_ms") val timeoutMs: Long? = null,
+ @SerializedName("input_files") val inputFiles: List? = null,
+ @SerializedName("expected_outputs") val expectedOutputs: List? = null,
+ @SerializedName("output_files") val outputFiles: List? = null,
+ @SerializedName("generated_files") val generatedFiles: List? = null,
+ val files: List? = null
+)
+
+data class AgentMemoryUpdate(
+ val category: String? = null,
+ val content: String? = null
+)
+
+data class AgentSkillUpdate(
+ val name: String? = null,
+ val description: String? = null,
+ @SerializedName("trigger_keywords") val triggerKeywords: List? = null,
+ @SerializedName("action_template") val actionTemplate: String? = null
+)
+
+data class AgentToolObservation(
+ val type: String,
+ val status: String,
+ val title: String? = null,
+ @SerializedName("final_url") val finalUrl: String? = null,
+ val text: String? = null,
+ val stdout: String? = null,
+ val stderr: String? = null,
+ @SerializedName("output_files") val outputFiles: List? = null,
+ val error: String? = null
+)
diff --git a/apps/Android/MnnLlmChat/app/src/main/java/com/alibaba/mnnllm/android/agent/AgenticPythonEngine.kt b/apps/Android/MnnLlmChat/app/src/main/java/com/alibaba/mnnllm/android/agent/AgenticPythonEngine.kt
new file mode 100644
index 0000000000..9b4a50f7d8
--- /dev/null
+++ b/apps/Android/MnnLlmChat/app/src/main/java/com/alibaba/mnnllm/android/agent/AgenticPythonEngine.kt
@@ -0,0 +1,111 @@
+package com.alibaba.mnnllm.android.agent
+
+import android.content.Context
+import android.util.Log
+import com.chaquo.python.Python
+import com.chaquo.python.android.AndroidPlatform
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.withContext
+import kotlinx.serialization.Serializable
+import kotlinx.serialization.decodeFromString
+import kotlinx.serialization.json.Json
+import kotlinx.serialization.json.JsonElement
+import java.io.File
+
+object AgenticPythonEngine {
+ private const val TAG = "AgenticPythonEngine"
+ private const val MAX_CODE_CHARS = 12_000
+ private const val MAX_INPUT_CHARS = 24_000
+ private const val MAX_OUTPUT_CHARS = 24_000
+ private const val DEFAULT_TIMEOUT_MS = 15_000L
+ private const val MAX_TIMEOUT_MS = 30_000L
+
+ private val json = Json { ignoreUnknownKeys = true }
+
+ @Volatile
+ private var initialized = false
+
+ @Volatile
+ private var appContext: Context? = null
+
+ fun initialize(context: Context) {
+ appContext = context.applicationContext
+ }
+
+ fun workspaceDir(context: Context? = appContext): File {
+ val base = requireNotNull(context?.applicationContext ?: appContext) {
+ "Python workspace requires initialized context."
+ }
+ return File(base.filesDir, "agent_workspace").apply { mkdirs() }
+ }
+
+ private fun ensureStarted(): Boolean {
+ if (initialized) return true
+ synchronized(this) {
+ if (initialized) return true
+ val context = appContext ?: return false
+ if (!Python.isStarted()) {
+ Python.start(AndroidPlatform(context))
+ }
+ initialized = true
+ Log.i(TAG, "python runtime initialized")
+ }
+ return true
+ }
+
+ suspend fun execute(code: String, input: String = "", timeoutMs: Long = DEFAULT_TIMEOUT_MS): AgenticPythonResult {
+ val trimmedCode = code.take(MAX_CODE_CHARS)
+ val trimmedInput = input.take(MAX_INPUT_CHARS)
+ val boundedTimeout = timeoutMs.coerceIn(1_000L, MAX_TIMEOUT_MS)
+ return withContext(Dispatchers.Default) {
+ if (!ensureStarted()) {
+ return@withContext AgenticPythonResult(
+ ok = false,
+ error = "Python runtime is not initialized."
+ )
+ }
+ runCatching {
+ val module = Python.getInstance().getModule("agent_python")
+ val raw = module.callAttr(
+ "run_code",
+ trimmedCode,
+ trimmedInput,
+ boundedTimeout,
+ workspaceDir().absolutePath
+ ).toString()
+ json.decodeFromString(raw)
+ }.getOrElse { error ->
+ Log.e(TAG, "python_exec failed: ${error.message}", error)
+ AgenticPythonResult(ok = false, error = error.message ?: error::class.java.simpleName)
+ }.trimmed()
+ }
+ }
+
+ private fun AgenticPythonResult.trimmed(): AgenticPythonResult {
+ return copy(
+ stdout = stdout.take(MAX_OUTPUT_CHARS),
+ stderr = stderr.take(MAX_OUTPUT_CHARS),
+ error = error.take(MAX_OUTPUT_CHARS)
+ )
+ }
+}
+
+@Serializable
+data class AgenticPythonResult(
+ val ok: Boolean = false,
+ val stdout: String = "",
+ val stderr: String = "",
+ val error: String = "",
+ val result: JsonElement? = null,
+ val files: List = emptyList(),
+ val elapsed_ms: Int = 0
+)
+
+@Serializable
+data class AgenticPythonFile(
+ val name: String = "",
+ val path: String = "",
+ val relative_path: String = "",
+ val mime_type: String = "",
+ val size_bytes: Long = 0L
+)
diff --git a/apps/Android/MnnLlmChat/app/src/main/java/com/alibaba/mnnllm/android/agent/AgenticToolExecutor.kt b/apps/Android/MnnLlmChat/app/src/main/java/com/alibaba/mnnllm/android/agent/AgenticToolExecutor.kt
new file mode 100644
index 0000000000..39865fb6d4
--- /dev/null
+++ b/apps/Android/MnnLlmChat/app/src/main/java/com/alibaba/mnnllm/android/agent/AgenticToolExecutor.kt
@@ -0,0 +1,531 @@
+// Copyright (c) 2024 Alibaba Group Holding Limited All rights reserved.
+
+package com.alibaba.mnnllm.android.agent
+
+import android.util.Base64
+import android.util.Log
+import com.alibaba.mnnllm.android.chat.model.ChatFileAttachment
+import kotlinx.coroutines.CancellationException
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.currentCoroutineContext
+import kotlinx.coroutines.ensureActive
+import kotlinx.coroutines.withContext
+import kotlinx.coroutines.withTimeoutOrNull
+import java.io.BufferedReader
+import java.io.File
+import java.io.InputStream
+import java.io.InputStreamReader
+import java.net.HttpURLConnection
+import java.net.URL
+import java.net.URLDecoder
+import java.net.URLEncoder
+import java.time.LocalDateTime
+import java.time.ZoneId
+import java.time.format.DateTimeFormatter
+import java.time.format.TextStyle
+import java.util.Locale
+import java.util.zip.GZIPInputStream
+
+object AgenticToolExecutor {
+ private const val TAG = "AgenticToolExecutor"
+ private const val MAX_SEARCH_RESULTS = 5
+ private const val MAX_PAGE_CHARS = 16_000
+ private const val USER_AGENT =
+ "Mozilla/5.0 (Linux; Android 14) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0 Mobile Safari/537.36"
+ private val fileReferenceRegex = Regex(
+ """(?i)(?:file://)?/?[\p{L}\p{N}_ .\-/\\%]+?\.(?:xlsx|xls|csv|pdf|txt|json|png|jpe?g|py)"""
+ )
+
+ data class ToolStepEvent(
+ val type: Type,
+ val title: String,
+ val detail: String = ""
+ ) {
+ enum class Type { STARTED, FINISHED, FAILED }
+ }
+
+ data class ToolExecutionResult(
+ val observations: String,
+ val generatedFiles: List = emptyList()
+ )
+
+ private data class SearchHit(
+ val title: String,
+ val url: String,
+ val snippet: String
+ )
+
+ suspend fun execute(
+ calls: List,
+ onStep: suspend (ToolStepEvent) -> Unit = {}
+ ): ToolExecutionResult {
+ val results = mutableListOf()
+ val generatedFiles = mutableListOf()
+ for (call in calls) {
+ currentCoroutineContext().ensureActive()
+ executeOne(call, onStep)?.let { result ->
+ results.add(result.observation)
+ generatedFiles.addAll(result.generatedFiles)
+ }
+ }
+ return ToolExecutionResult(
+ observations = results.joinToString("\n---\n"),
+ generatedFiles = generatedFiles
+ )
+ }
+
+ fun resolveMentionedWorkspaceFiles(text: String): List {
+ if (text.isBlank()) return emptyList()
+ val references = fileReferenceRegex.findAll(text)
+ .map { it.value }
+ .toList()
+ return resolveWorkspaceFileReferences(references)
+ }
+
+ fun resolveWorkspaceFileReferences(references: List): List {
+ if (references.isEmpty()) return emptyList()
+ return references.mapNotNull { resolveWorkspaceFileReference(it) }
+ .distinctBy { it.path }
+ }
+
+ private data class SingleToolResult(
+ val observation: String,
+ val generatedFiles: List = emptyList()
+ )
+
+ private suspend fun executeOne(
+ call: AgentSystemCall,
+ onStep: suspend (ToolStepEvent) -> Unit
+ ): SingleToolResult? {
+ val title = toolTitle(call)
+ val detail = toolDetail(call)
+ onStep(ToolStepEvent(ToolStepEvent.Type.STARTED, title, detail))
+ return try {
+ val result = when (normalizeType(call.type)) {
+ "get_current_time" -> SingleToolResult(executeGetCurrentTime())
+ "web_search" -> SingleToolResult(executeWebSearch(call.query.orEmpty()))
+ "browser_url", "browse_url", "web_browse", "open_url" ->
+ SingleToolResult(executeBrowseUrl(call.url.orEmpty().ifBlank { call.query.orEmpty() }))
+ "python_exec", "run_python", "python" -> executePython(call)
+ else -> SingleToolResult("[TOOL_ERROR] Unknown system call: ${call.type}")
+ }
+ onStep(ToolStepEvent(ToolStepEvent.Type.FINISHED, title, summarizeToolResult(result.observation)))
+ result
+ } catch (e: CancellationException) {
+ throw e
+ } catch (e: Exception) {
+ val message = e.message ?: e::class.java.simpleName
+ onStep(ToolStepEvent(ToolStepEvent.Type.FAILED, title, message))
+ SingleToolResult("[TOOL_ERROR] $title failed: $message")
+ }
+ }
+
+ private fun toolTitle(call: AgentSystemCall): String {
+ return when (normalizeType(call.type)) {
+ "get_current_time" -> "Get current time"
+ "web_search" -> "Web search"
+ "browser_url", "browse_url", "web_browse", "open_url" -> "Read web page"
+ "python_exec", "run_python", "python" -> "Run Python"
+ else -> "Run tool"
+ }
+ }
+
+ private fun toolDetail(call: AgentSystemCall): String {
+ return when (normalizeType(call.type)) {
+ "web_search" -> call.query.orEmpty()
+ "browser_url", "browse_url", "web_browse", "open_url" -> call.url.orEmpty().ifBlank { call.query.orEmpty() }
+ "python_exec", "run_python", "python" ->
+ call.code.orEmpty().lineSequence().firstOrNull()?.trim().orEmpty().ifBlank { "python_exec" }
+ else -> call.type.orEmpty()
+ }.take(240)
+ }
+
+ private fun summarizeToolResult(result: String): String {
+ return when {
+ result.contains("[SEARCH_RESULT]") -> "Search completed, ${result.length} chars"
+ result.contains("[BROWSE_RESULT]") -> "Page read, ${result.length} chars"
+ result.contains("[PYTHON_RESULT]") -> "Python completed, ${result.length} chars"
+ result.contains("[PYTHON_ERROR]") -> "Python failed"
+ result.contains("[TIME_RESULT]") -> "Time read"
+ result.contains("[TOOL_ERROR]") ||
+ result.contains("[SEARCH_ERROR]") ||
+ result.contains("[BROWSE_ERROR]") -> "Tool failed"
+ else -> "Done, ${result.length} chars"
+ }
+ }
+
+ private fun normalizeType(type: String?): String {
+ return type.orEmpty().trim().lowercase(Locale.US)
+ }
+
+ private fun executeGetCurrentTime(): String {
+ val zone = ZoneId.systemDefault()
+ val now = LocalDateTime.now(zone)
+ val formatted = now.format(DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss"))
+ val weekday = now.dayOfWeek.getDisplayName(TextStyle.FULL, Locale.getDefault())
+ val epochMs = now.atZone(zone).toInstant().toEpochMilli()
+ return """
+ [TIME_RESULT]
+ datetime: $formatted
+ weekday: $weekday
+ timezone: ${zone.id}
+ epoch_ms: $epochMs
+ """.trimIndent()
+ }
+
+ private suspend fun executePython(call: AgentSystemCall): SingleToolResult {
+ val code = call.code.orEmpty().ifBlank { call.query.orEmpty() }
+ if (code.isBlank()) return SingleToolResult("[PYTHON_ERROR] Empty Python code.")
+ val result = AgenticPythonEngine.execute(
+ code = code,
+ input = call.input.orEmpty(),
+ timeoutMs = normalizePythonTimeout(code, call.timeoutMs)
+ )
+ val files = result.files.mapNotNull { file ->
+ if (file.path.isBlank()) {
+ null
+ } else {
+ ChatFileAttachment(
+ name = file.name.ifBlank { java.io.File(file.path).name },
+ path = file.path,
+ mimeType = file.mime_type,
+ sizeBytes = file.size_bytes
+ )
+ }
+ } + resolveDeclaredOutputFiles(call)
+ val observation = buildString {
+ appendLine(if (result.ok) "[PYTHON_RESULT]" else "[PYTHON_ERROR]")
+ appendLine("elapsed_ms: ${result.elapsed_ms}")
+ if (result.stdout.isNotBlank()) {
+ appendLine("stdout:")
+ appendLine(result.stdout.trimEnd())
+ }
+ if (result.result != null) {
+ appendLine("result:")
+ appendLine(result.result.toString())
+ }
+ if (result.stderr.isNotBlank()) {
+ appendLine("stderr:")
+ appendLine(result.stderr.trimEnd())
+ }
+ if (result.error.isNotBlank()) {
+ appendLine("error:")
+ appendLine(result.error.trimEnd())
+ }
+ if (files.isNotEmpty()) {
+ appendLine("files:")
+ files.forEach { file ->
+ appendLine("- ${file.name} | ${file.path} | ${file.mimeType} | ${file.sizeBytes} bytes")
+ }
+ }
+ }.trim()
+ return SingleToolResult(observation, files)
+ }
+
+ private fun resolveDeclaredOutputFiles(call: AgentSystemCall): List {
+ val references = buildList {
+ addAll(call.outputFiles.orEmpty())
+ addAll(call.generatedFiles.orEmpty())
+ addAll(call.expectedOutputs.orEmpty())
+ addAll(call.files.orEmpty())
+ }
+ return resolveWorkspaceFileReferences(references)
+ }
+
+ private fun resolveWorkspaceFileReference(rawReference: String): ChatFileAttachment? {
+ val workspace = runCatching { AgenticPythonEngine.workspaceDir().canonicalFile }.getOrNull()
+ ?: return null
+ val decoded = runCatching { URLDecoder.decode(rawReference.trim(), "UTF-8") }
+ .getOrElse { rawReference.trim() }
+ .trim('"', '\'', '`', ' ', '\n', '\r', '\t', '(', ')', '[', ']', '<', '>')
+ if (decoded.isBlank()) return null
+
+ val withoutScheme = decoded
+ .removePrefix("file://localhost")
+ .removePrefix("file://")
+ .replace('\\', '/')
+
+ val candidates = mutableListOf()
+ val direct = File(withoutScheme)
+ if (direct.isAbsolute) {
+ candidates += direct
+ candidates += File(workspace, withoutScheme.trimStart('/'))
+ } else {
+ candidates += File(workspace, withoutScheme)
+ }
+ candidates += File(workspace, File(withoutScheme).name)
+
+ val found = candidates.asSequence()
+ .mapNotNull { candidate ->
+ runCatching { candidate.canonicalFile }.getOrNull()
+ }
+ .firstOrNull { candidate ->
+ candidate.isFile && candidate.path.startsWith(workspace.path + File.separator)
+ } ?: findWorkspaceFileByName(workspace, File(withoutScheme).name)
+ ?: return null
+
+ return ChatFileAttachment(
+ name = found.name,
+ path = found.absolutePath,
+ mimeType = guessMimeType(found.name),
+ sizeBytes = found.length()
+ )
+ }
+
+ private fun findWorkspaceFileByName(workspace: File, name: String): File? {
+ if (name.isBlank() || !workspace.exists()) return null
+ return workspace.walkTopDown()
+ .onEnter { dir -> dir.name != "__pycache__" && dir.name != "python" }
+ .filter { it.isFile && it.name == name }
+ .maxByOrNull { it.lastModified() }
+ ?.canonicalFile
+ ?.takeIf { it.path.startsWith(workspace.path + File.separator) }
+ }
+
+ private fun normalizePythonTimeout(code: String, requestedTimeoutMs: Long?): Long {
+ val requested = requestedTimeoutMs ?: 15_000L
+ val needsDataPackageWarmup = listOf(
+ "import pandas",
+ "from pandas",
+ "import numpy",
+ "from numpy",
+ "import openpyxl",
+ "from openpyxl",
+ "read_excel(",
+ "write_excel(",
+ ".to_excel("
+ ).any { code.contains(it) }
+ val minimum = if (needsDataPackageWarmup) 15_000L else 1_000L
+ return requested.coerceAtLeast(minimum).coerceAtMost(30_000L)
+ }
+
+ private fun guessMimeType(name: String): String {
+ val lower = name.lowercase(Locale.US)
+ return when {
+ lower.endsWith(".xlsx") -> "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"
+ lower.endsWith(".xls") -> "application/vnd.ms-excel"
+ lower.endsWith(".csv") -> "text/csv"
+ lower.endsWith(".pdf") -> "application/pdf"
+ lower.endsWith(".txt") -> "text/plain"
+ lower.endsWith(".json") -> "application/json"
+ lower.endsWith(".png") -> "image/png"
+ lower.endsWith(".jpg") || lower.endsWith(".jpeg") -> "image/jpeg"
+ lower.endsWith(".py") -> "text/x-python"
+ else -> "application/octet-stream"
+ }
+ }
+
+ private suspend fun executeWebSearch(query: String): String = withContext(Dispatchers.IO) {
+ if (query.isBlank()) return@withContext "[SEARCH_ERROR] Empty query."
+ val result = withTimeoutOrNull(12_000L) {
+ searchBing(query)
+ }
+ if (result.isNullOrEmpty()) {
+ "[SEARCH_ERROR] No search results were returned."
+ } else {
+ buildString {
+ appendLine("[SEARCH_RESULT]")
+ appendLine("query: $query")
+ appendLine("source: Bing")
+ result.take(MAX_SEARCH_RESULTS).forEachIndexed { index, hit ->
+ appendLine("${index + 1}. ${hit.title}")
+ appendLine(" url: ${hit.url}")
+ if (hit.snippet.isNotBlank()) appendLine(" snippet: ${hit.snippet}")
+ }
+ }.trim()
+ }
+ }
+
+ private fun searchBing(query: String): List {
+ val encoded = URLEncoder.encode(query, "UTF-8")
+ val url = URL("https://www.bing.com/search?q=$encoded&form=QBRE&pq=$encoded&qs=n&sp=-1&lq=0")
+ Log.d(TAG, "BING-REQ: $url")
+ val conn = openHttp(url)
+ val html = try {
+ readResponseBody(conn)
+ } finally {
+ conn.disconnect()
+ }
+ val results = parseBingHtml(html)
+ Log.d(TAG, "BING-RESULTS: ${results.size}")
+ return results
+ }
+
+ private suspend fun executeBrowseUrl(rawUrl: String): String = withContext(Dispatchers.IO) {
+ val normalized = normalizeHttpUrl(rawUrl)
+ ?: return@withContext "[BROWSE_ERROR] Invalid URL; only http/https pages are supported."
+ val text = withTimeoutOrNull(15_000L) {
+ fetchUrlText(normalized)
+ }
+ if (text.isNullOrBlank()) {
+ "[BROWSE_ERROR] Page content could not be read."
+ } else {
+ "[BROWSE_RESULT]\n$text"
+ }
+ }
+
+ private fun fetchUrlText(url: String): String {
+ val parsed = URL(url)
+ val conn = openHttp(parsed)
+ val html = try {
+ readResponseBody(conn)
+ } finally {
+ conn.disconnect()
+ }
+ val title = extractHtmlTitle(html)
+ val text = htmlToReadableText(html)
+ return buildString {
+ appendLine("final_url: $url")
+ if (title.isNotBlank()) appendLine("title: $title")
+ appendLine("text:")
+ append(text.take(MAX_PAGE_CHARS))
+ }
+ }
+
+ private fun openHttp(url: URL): HttpURLConnection {
+ return (url.openConnection() as HttpURLConnection).apply {
+ connectTimeout = 8_000
+ readTimeout = 10_000
+ instanceFollowRedirects = true
+ setRequestProperty("User-Agent", USER_AGENT)
+ setRequestProperty("Accept", "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8")
+ setRequestProperty("Accept-Language", "zh-CN,zh;q=0.9,en;q=0.8")
+ setRequestProperty("Accept-Encoding", "gzip")
+ setRequestProperty("Cache-Control", "no-cache")
+ }
+ }
+
+ private fun readResponseBody(conn: HttpURLConnection): String {
+ val stream = runCatching {
+ if (conn.responseCode >= 400) conn.errorStream else conn.inputStream
+ }.getOrNull() ?: return ""
+ val input = if (conn.contentEncoding.equals("gzip", ignoreCase = true)) {
+ GZIPInputStream(stream)
+ } else {
+ stream
+ }
+ return input.use { readText(it, responseCharset(conn.contentType)) }
+ }
+
+ private fun readText(input: InputStream, charset: String): String {
+ return BufferedReader(InputStreamReader(input, charset)).use { it.readText() }
+ }
+
+ private fun responseCharset(contentType: String?): String {
+ val match = Regex("charset=([^;]+)", RegexOption.IGNORE_CASE).find(contentType.orEmpty())
+ return match?.groupValues?.getOrNull(1)?.trim('"', '\'', ' ')?.ifBlank { null } ?: "UTF-8"
+ }
+
+ private fun parseBingHtml(html: String): List {
+ val results = mutableListOf()
+ val itemRegex = Regex("]*class=\"[^\"]*b_algo[^\"]*\"[\\s\\S]*?", RegexOption.IGNORE_CASE)
+ for (item in itemRegex.findAll(html)) {
+ if (results.size >= MAX_SEARCH_RESULTS) break
+ val block = item.value
+ val link = Regex("]*>[\\s\\S]*?]*href=\"([^\"]+)\"[^>]*>([\\s\\S]*?)", RegexOption.IGNORE_CASE)
+ .find(block)
+ val url = normalizeBingHref(link?.groupValues?.getOrNull(1).orEmpty())
+ val title = stripAll(link?.groupValues?.getOrNull(2).orEmpty())
+ val snippet = Regex("
]*>([\\s\\S]*?)
", RegexOption.IGNORE_CASE)
+ .find(block)
+ ?.groupValues
+ ?.getOrNull(1)
+ ?.let(::stripAll)
+ .orEmpty()
+ if (title.isNotBlank() && url.isNotBlank()) {
+ results.add(SearchHit(title, url, snippet))
+ }
+ }
+ return results
+ }
+
+ private fun normalizeBingHref(raw: String): String {
+ val decoded = htmlDecode(raw)
+ return if (decoded.contains("/ck/a?", ignoreCase = true) && decoded.contains("u=")) {
+ val u = Regex("[?&]u=([^&]+)").find(decoded)?.groupValues?.getOrNull(1)
+ if (u.isNullOrBlank()) decoded else decodeBingRedirectUrl(u) ?: decoded
+ } else {
+ decoded
+ }
+ }
+
+ private fun decodeBingRedirectUrl(raw: String): String? {
+ val decoded = runCatching { URLDecoder.decode(raw, "UTF-8") }.getOrNull() ?: return null
+ if (decoded.startsWith("http://", ignoreCase = true) || decoded.startsWith("https://", ignoreCase = true)) {
+ return decoded
+ }
+ if (decoded.startsWith("a1") && decoded.length > 2) {
+ return runCatching {
+ String(
+ Base64.decode(decoded.substring(2), Base64.URL_SAFE or Base64.NO_PADDING or Base64.NO_WRAP),
+ Charsets.UTF_8
+ )
+ }.getOrNull()?.takeIf {
+ it.startsWith("http://", ignoreCase = true) || it.startsWith("https://", ignoreCase = true)
+ }
+ }
+ return null
+ }
+
+ private fun normalizeHttpUrl(rawUrl: String): String? {
+ val trimmed = rawUrl.trim()
+ if (trimmed.isBlank()) return null
+ val withScheme = if (
+ trimmed.startsWith("http://", ignoreCase = true) ||
+ trimmed.startsWith("https://", ignoreCase = true)
+ ) {
+ trimmed
+ } else {
+ "https://$trimmed"
+ }
+ val parsed = runCatching { URL(withScheme) }.getOrNull() ?: return null
+ val protocol = parsed.protocol.lowercase(Locale.US)
+ if (protocol != "http" && protocol != "https") return null
+ if (parsed.host.isNullOrBlank()) return null
+ return parsed.toString()
+ }
+
+ private fun extractHtmlTitle(html: String): String {
+ return Regex("]*>([\\s\\S]*?)", RegexOption.IGNORE_CASE)
+ .find(html)
+ ?.groupValues
+ ?.getOrNull(1)
+ ?.let(::stripAll)
+ .orEmpty()
+ }
+
+ private fun htmlToReadableText(html: String): String {
+ val body = Regex("]*>([\\s\\S]*?)", RegexOption.IGNORE_CASE)
+ .find(html)
+ ?.groupValues
+ ?.getOrNull(1)
+ ?: html
+ val cleaned = body
+ .replace(Regex("