diff --git a/ai-assistant/ai-assistant.html b/ai-assistant/ai-assistant.html index a0f5cf25..8589741c 100644 --- a/ai-assistant/ai-assistant.html +++ b/ai-assistant/ai-assistant.html @@ -70,6 +70,10 @@

2. Core Functionality

and generate code from templates.
  • Dual inference backends — fully offline on-device inference, or Gemini in the cloud, selectable in Settings.
  • +
  • Direct commands — explicit requests like "open MainActivity.java", + "list files", "read <file>" or "search <query>" run the tool + directly (resolving a bare filename to its path), so they work reliably on + any model.
  • Safety controls — filesystem tools are confined to the project root, and mutating tools require explicit user approval.
  • diff --git a/ai-assistant/build.gradle.kts b/ai-assistant/build.gradle.kts index b376da7c..e8032dc3 100644 --- a/ai-assistant/build.gradle.kts +++ b/ai-assistant/build.gradle.kts @@ -45,6 +45,10 @@ android { } } + testOptions { + unitTests.isReturnDefaultValues = true + } + packaging { resources { excludes += setOf( @@ -77,10 +81,10 @@ dependencies { // JSON serialization for session persistence implementation("com.google.code.gson:gson:2.10.1") - // Plugin dependencies are loaded at runtime by the plugin manager - // No explicit compile-time dependency on the ai-core plugin needed + testImplementation(files("../libs/plugin-api.jar")) testImplementation("junit:junit:4.13.2") testImplementation("io.mockk:mockk:1.13.8") + testImplementation("org.json:json:20231013") testImplementation("org.jetbrains.kotlinx:kotlinx-coroutines-test:1.7.3") testImplementation("androidx.arch.core:core-testing:2.2.0") } diff --git a/ai-assistant/src/main/AndroidManifest.xml b/ai-assistant/src/main/AndroidManifest.xml index 78fed1cc..ca64c28a 100644 --- a/ai-assistant/src/main/AndroidManifest.xml +++ b/ai-assistant/src/main/AndroidManifest.xml @@ -1,6 +1,5 @@ - + { holder.loadingIndicator.visibility = View.VISIBLE holder.messageContent.visibility = View.GONE - holder.generatingDots.visibility = View.GONE + stopGeneratingDots(holder) } MessageStatus.SENT -> { holder.loadingIndicator.visibility = View.GONE @@ -130,19 +134,19 @@ class ChatAdapter( if (message.sender == Sender.AGENT && message.durationMs == null) { animateGeneratingDots(holder) } else { - holder.generatingDots.visibility = View.GONE + stopGeneratingDots(holder) } } MessageStatus.COMPLETED -> { holder.loadingIndicator.visibility = View.GONE holder.messageContent.visibility = View.VISIBLE - holder.generatingDots.visibility = View.GONE + stopGeneratingDots(holder) markwon.setMarkdown(holder.messageContent, payload.text) } MessageStatus.ERROR -> { holder.loadingIndicator.visibility = View.GONE holder.messageContent.visibility = View.VISIBLE - holder.generatingDots.visibility = View.GONE + stopGeneratingDots(holder) holder.messageContent.text = payload.text } } @@ -185,14 +189,14 @@ class ChatAdapter( if (message.sender == Sender.AGENT && message.durationMs == null) { animateGeneratingDots(holder) } else { - holder.generatingDots.visibility = View.GONE + stopGeneratingDots(holder) } } MessageStatus.COMPLETED -> { holder.loadingIndicator.visibility = View.GONE holder.messageContent.visibility = View.VISIBLE holder.btnRetry.visibility = View.GONE - holder.generatingDots.visibility = View.GONE + stopGeneratingDots(holder) markwon.setMarkdown(holder.messageContent, message.text) updateMessageMetadata(holder, message) } @@ -200,15 +204,15 @@ class ChatAdapter( holder.loadingIndicator.visibility = View.GONE holder.messageContent.visibility = View.VISIBLE holder.btnRetry.visibility = View.VISIBLE - holder.generatingDots.visibility = View.GONE + stopGeneratingDots(holder) holder.messageContent.text = message.text if (message.sender == Sender.SYSTEM) { - holder.btnRetry.text = "Open AI Settings" + holder.btnRetry.text = holder.btnRetry.context.getString(R.string.action_open_settings) holder.btnRetry.setOnClickListener { onMessageAction(ACTION_OPEN_SETTINGS, message) } } else { - holder.btnRetry.text = "Retry" + holder.btnRetry.text = holder.btnRetry.context.getString(R.string.action_retry) holder.btnRetry.setOnClickListener { onMessageAction(ACTION_RETRY, message) } @@ -236,7 +240,7 @@ class ChatAdapter( private fun updateSystemMessageExpansion(holder: SystemMessageViewHolder, message: ChatMessage) { val isExpanded = expandedMessageIds.contains(message.id) if (isExpanded) { - holder.messageHeaderTitle.text = "System Log" + holder.messageHeaderTitle.text = holder.messageHeaderTitle.context.getString(R.string.system_log) holder.messageContent.visibility = View.VISIBLE holder.expandIcon.rotation = 180f } else { @@ -247,21 +251,36 @@ class ChatAdapter( } private fun animateGeneratingDots(holder: DefaultMessageViewHolder) { + // Already animating: don't restart, or each streaming-token rebind would + // reset the loop to "." and churn a new Runnable. Torn down on complete/recycle. + if (holder.dotsRunnable != null) return holder.generatingDots.visibility = View.VISIBLE val dotStates = arrayOf(".", "..", "...") var currentIndex = 0 - val handler = android.os.Handler(android.os.Looper.getMainLooper()) val runnable = object : Runnable { override fun run() { - if (holder.generatingDots.visibility == View.VISIBLE) { - holder.generatingDots.text = dotStates[currentIndex] - currentIndex = (currentIndex + 1) % dotStates.size - handler.postDelayed(this, 500) - } + holder.generatingDots.text = dotStates[currentIndex] + currentIndex = (currentIndex + 1) % dotStates.size + holder.dotsHandler.postDelayed(this, 500) } } - handler.post(runnable) + holder.dotsRunnable = runnable + holder.dotsHandler.post(runnable) + } + + /** Stop and clear this holder's "…" animation. Safe to call repeatedly. */ + private fun stopGeneratingDots(holder: DefaultMessageViewHolder) { + holder.dotsRunnable?.let { holder.dotsHandler.removeCallbacks(it) } + holder.dotsRunnable = null + holder.generatingDots.apply { visibility = View.GONE } + } + + override fun onViewRecycled(holder: RecyclerView.ViewHolder) { + super.onViewRecycled(holder) + if (holder is DefaultMessageViewHolder) { + stopGeneratingDots(holder) + } } private fun createPreview(rawText: String): String { @@ -334,7 +353,7 @@ class ChatAdapter( val clipboard = context.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager val clip = ClipData.newPlainText("chat_message", message.text) clipboard.setPrimaryClip(clip) - Toast.makeText(context, "Copied", Toast.LENGTH_SHORT).show() + Snackbar.make(view, view.context.getString(R.string.msg_copied), Snackbar.LENGTH_SHORT).show() true } 2 -> { diff --git a/ai-assistant/src/main/kotlin/com/itsaky/androidide/plugins/aiassistant/fragments/AiSettingsFragment.kt b/ai-assistant/src/main/kotlin/com/itsaky/androidide/plugins/aiassistant/fragments/AiSettingsFragment.kt index 83c285c1..e1560b8a 100644 --- a/ai-assistant/src/main/kotlin/com/itsaky/androidide/plugins/aiassistant/fragments/AiSettingsFragment.kt +++ b/ai-assistant/src/main/kotlin/com/itsaky/androidide/plugins/aiassistant/fragments/AiSettingsFragment.kt @@ -73,9 +73,9 @@ class AiSettingsFragment : DialogFragment() { val uriString = it.toString() viewModel.loadModelFromUri(uriString, requireContext()) - Toast.makeText(requireContext(), "Loading model...", Toast.LENGTH_SHORT).show() + Toast.makeText(requireContext(), getString(R.string.model_loading_toast), Toast.LENGTH_SHORT).show() } catch (e: Exception) { - Toast.makeText(requireContext(), "Error: ${e.message}", Toast.LENGTH_LONG).show() + Toast.makeText(requireContext(), getString(R.string.state_error, e.message), Toast.LENGTH_LONG).show() } } } @@ -233,12 +233,12 @@ class AiSettingsFragment : DialogFragment() { viewModel.engineState.observe(viewLifecycleOwner) { state -> when (state) { is EngineState.Initializing, EngineState.Uninitialized -> { - engineStatusTextView.text = "Initializing engine..." + engineStatusTextView.text = getString(R.string.engine_initializing) browseButton.isEnabled = false loadSavedButton.isEnabled = false } is EngineState.Initialized -> { - engineStatusTextView.text = "Engine ready" + engineStatusTextView.text = getString(R.string.engine_ready) browseButton.isEnabled = true loadSavedButton.isEnabled = viewModel.savedModelPath.value != null } @@ -257,7 +257,7 @@ class AiSettingsFragment : DialogFragment() { if (path != null) { modelPathTextView.visibility = View.VISIBLE val fileName = viewModel.getSavedModelName() ?: viewModel.fallbackDisplayName(path) - modelPathTextView.text = "Saved: $fileName" + modelPathTextView.text = getString(R.string.model_saved_path, fileName) } else { modelPathTextView.visibility = View.GONE } @@ -268,19 +268,19 @@ class AiSettingsFragment : DialogFragment() { when (state) { is ModelLoadingState.Idle -> { modelStatusTextView.visibility = View.VISIBLE - modelStatusTextView.text = "No model is currently loaded" + modelStatusTextView.text = getString(R.string.model_none_loaded) } is ModelLoadingState.Loading -> { modelStatusTextView.visibility = View.VISIBLE - modelStatusTextView.text = "Loading model, please wait..." + modelStatusTextView.text = getString(R.string.model_loading_wait) } is ModelLoadingState.Loaded -> { modelStatusTextView.visibility = View.VISIBLE - modelStatusTextView.text = "✅ Model loaded: ${state.modelName}" + modelStatusTextView.text = getString(R.string.model_loaded, state.modelName) } is ModelLoadingState.Error -> { modelStatusTextView.visibility = View.VISIBLE - modelStatusTextView.text = "❌ Error: ${state.message}" + modelStatusTextView.text = getString(R.string.model_load_error, state.message) } } } @@ -328,9 +328,9 @@ class AiSettingsFragment : DialogFragment() { if (timestamp > 0) { val sdf = SimpleDateFormat("MMMM d, yyyy", Locale.getDefault()) val savedDate = sdf.format(Date(timestamp)) - statusTextView.text = "API Key saved on: $savedDate" + statusTextView.text = getString(R.string.api_key_saved_on, savedDate) } else { - statusTextView.text = "API Key is saved" + statusTextView.text = getString(R.string.api_key_saved) } } @@ -338,15 +338,15 @@ class AiSettingsFragment : DialogFragment() { val apiKey = apiKeyInput.text.toString() if (apiKey.isNotBlank()) { viewModel.saveGeminiApiKey(apiKey) - Toast.makeText(requireContext(), "API Key saved", Toast.LENGTH_SHORT).show() + Toast.makeText(requireContext(), getString(R.string.api_key_saved_toast), Toast.LENGTH_SHORT).show() updateUiState(isEditing = false) val timestamp = viewModel.getGeminiApiKeySaveTimestamp() val sdf = SimpleDateFormat("MMMM d, yyyy", Locale.getDefault()) val savedDate = sdf.format(Date(timestamp)) - statusTextView.text = "API Key saved on: $savedDate" + statusTextView.text = getString(R.string.api_key_saved_on, savedDate) } else { - Toast.makeText(requireContext(), "API Key cannot be empty", Toast.LENGTH_SHORT).show() + Toast.makeText(requireContext(), getString(R.string.api_key_empty), Toast.LENGTH_SHORT).show() } } @@ -358,7 +358,7 @@ class AiSettingsFragment : DialogFragment() { clearButton.setOnClickListener { viewModel.clearGeminiApiKey() - Toast.makeText(requireContext(), "API Key cleared", Toast.LENGTH_SHORT).show() + Toast.makeText(requireContext(), getString(R.string.api_key_cleared), Toast.LENGTH_SHORT).show() updateUiState(isEditing = true) apiKeyInput.setText("") } @@ -462,7 +462,7 @@ class AiSettingsFragment : DialogFragment() { // Observe loading state viewModel.geminiModelsLoading.observe(viewLifecycleOwner) { isLoading -> refreshButton.isEnabled = !isLoading - refreshButton.text = if (isLoading) "Loading..." else "Refresh Models" + refreshButton.text = if (isLoading) getString(R.string.loading) else getString(R.string.refresh_models) } modelSpinner.setOnTouchListener { _, _ -> @@ -478,8 +478,8 @@ class AiSettingsFragment : DialogFragment() { val selectedModel = parent?.getItemAtPosition(position) as? String if (selectedModel != null && selectedModel != viewModel.getGeminiModel()) { viewModel.saveGeminiModel(selectedModel) - currentModelText?.text = "Current: $selectedModel" - Toast.makeText(requireContext(), "Model changed to $selectedModel", Toast.LENGTH_SHORT).show() + currentModelText?.text = getString(R.string.current_model, selectedModel) + Toast.makeText(requireContext(), getString(R.string.model_changed, selectedModel), Toast.LENGTH_SHORT).show() } } diff --git a/ai-assistant/src/main/kotlin/com/itsaky/androidide/plugins/aiassistant/fragments/ChatFragment.kt b/ai-assistant/src/main/kotlin/com/itsaky/androidide/plugins/aiassistant/fragments/ChatFragment.kt index a293646f..1f7d9cc0 100644 --- a/ai-assistant/src/main/kotlin/com/itsaky/androidide/plugins/aiassistant/fragments/ChatFragment.kt +++ b/ai-assistant/src/main/kotlin/com/itsaky/androidide/plugins/aiassistant/fragments/ChatFragment.kt @@ -14,6 +14,7 @@ import androidx.lifecycle.repeatOnLifecycle import androidx.recyclerview.widget.LinearLayoutManager import com.google.android.material.chip.Chip import com.itsaky.androidide.plugins.PluginContext +import com.itsaky.androidide.plugins.aiassistant.R import com.itsaky.androidide.plugins.aiassistant.adapters.ChatAdapter import com.itsaky.androidide.plugins.aiassistant.databinding.FragmentChatBinding import com.itsaky.androidide.plugins.aiassistant.models.AgentState @@ -287,33 +288,32 @@ class ChatFragment : Fragment() { is AgentState.Idle -> { binding.agentStatusContainer.isVisible = false binding.sendButton.isEnabled = true - binding.sendButton.text = "Send" + binding.sendButton.text = getString(R.string.send) } is AgentState.Executing -> { binding.agentStatusContainer.isVisible = true binding.agentStatusMessage.text = state.formattedProgress binding.agentStatusTimer.text = state.formattedTiming binding.sendButton.isEnabled = true - binding.sendButton.text = "Stop" - viewModel.startStateTimer(state) + binding.sendButton.text = getString(R.string.btn_stop) } is AgentState.Processing -> { binding.agentStatusContainer.isVisible = true - binding.agentStatusMessage.text = "Generating response..." + binding.agentStatusMessage.text = getString(R.string.generating_response) binding.agentStatusTimer.text = "" binding.sendButton.isEnabled = true - binding.sendButton.text = "Stop" + binding.sendButton.text = getString(R.string.btn_stop) } is AgentState.Error -> { binding.agentStatusContainer.isVisible = false binding.sendButton.isEnabled = true - binding.sendButton.text = "Send" + binding.sendButton.text = getString(R.string.send) viewModel.stopStateTimer() showErrorSnackbar(state.message) } else -> { binding.sendButton.isEnabled = false - binding.sendButton.text = "Send" + binding.sendButton.text = getString(R.string.send) } } } diff --git a/ai-assistant/src/main/kotlin/com/itsaky/androidide/plugins/aiassistant/tool/AgentLoop.kt b/ai-assistant/src/main/kotlin/com/itsaky/androidide/plugins/aiassistant/tool/AgentLoop.kt new file mode 100644 index 00000000..8c7e8f04 --- /dev/null +++ b/ai-assistant/src/main/kotlin/com/itsaky/androidide/plugins/aiassistant/tool/AgentLoop.kt @@ -0,0 +1,215 @@ +package com.itsaky.androidide.plugins.aiassistant.tool + +import com.itsaky.androidide.plugins.aiassistant.models.ToolResult +import com.itsaky.androidide.plugins.services.LlmInferenceService +import com.itsaky.androidide.plugins.services.LlmInferenceService.ChatMessage +import com.itsaky.androidide.plugins.services.LlmInferenceService.ChatMessage.Role + +/** + * The agentic tool-loop: each turn renders the transcript into a prompt, generates a + * reply, and runs any tool calls, looping until the model stops or a limit is hit. + * Free of Android/coroutine/UI deps so it unit-tests with plain fakes. + */ +class AgentLoop( + private val maxIterations: Int = DEFAULT_MAX_ITERATIONS, + private val toolOutputCharLimit: Int = DEFAULT_TOOL_OUTPUT_CHAR_LIMIT, + private val maxConsecutiveRepeats: Int = DEFAULT_MAX_CONSECUTIVE_REPEATS, + private val extractToolCalls: (String) -> List = ToolCallExtractor::extractToolCalls, + private val terminalTool: String? = null, +) { + + companion object { + /** Max model turns per user message; a backstop against a model that never stops calling tools. */ + const val DEFAULT_MAX_ITERATIONS = 8 + + /** Per-tool-result cap fed back into the prompt, so big outputs don't blow a local model's context. */ + const val DEFAULT_TOOL_OUTPUT_CHAR_LIMIT = 4000 + + /** + * Consecutive identical tool-call batches tolerated before aborting as + * [StopReason.REPEATED]; a truncated result can make one repeat legitimate. + */ + const val DEFAULT_MAX_CONSECUTIVE_REPEATS = 2 + } + + /** Callbacks so the caller can drive UI/state; all no-ops by default. */ + interface Events { + /** + * A model turn finished. + * @param turn 1-based turn index. + * @param text the model's reply, already streamed into the UI. + */ + suspend fun onModelTurn(turn: Int, text: String) {} + + /** + * A tool batch was executed; the caller renders it. + * @param turn 1-based turn index. + * @param calls the tool calls that ran. + * @param results their results, positionally aligned with [calls]. + */ + suspend fun onToolResults(turn: Int, calls: List, results: List) {} + + /** + * The loop stopped after hitting the iteration cap while still calling tools. + * @param turns total turns run. + */ + suspend fun onMaxIterationsReached(turns: Int) {} + + /** + * The loop stopped after the model repeated identical tool calls. + * @param turns total turns run. + */ + suspend fun onRepeatedToolCalls(turns: Int) {} + + /** + * The model called the terminal tool to finish. + * @param turn 1-based turn index. + * @param message the model's final answer. + */ + suspend fun onFinalAnswer(turn: Int, message: String) {} + } + + /** Why the loop stopped. */ + enum class StopReason { COMPLETED, MAX_ITERATIONS, REPEATED } + + /** + * Outcome of a run; [completed] is true when the model ended on its own. + * @property turns model turns executed. + * @property reason why the loop stopped. + */ + data class Result(val turns: Int, val reason: StopReason) { + val completed: Boolean get() = reason == StopReason.COMPLETED + } + + /** + * Runs the tool loop until the model stops calling tools or a limit is hit. + * @param history transcript, mutated in place; seed it with the user message. + * @param generate renders one model turn from a prompt. + * @param executeTools runs a batch of tool calls. + * @param events UI/state callbacks. + * @return the run [Result]. + */ + suspend fun run( + history: MutableList, + generate: suspend (prompt: String) -> String, + executeTools: suspend (List) -> List, + events: Events = object : Events {}, + ): Result { + var turn = 0 + var previousSignature: String? = null + var consecutiveRepeats = 0 + while (turn < maxIterations) { + turn++ + + val text = generate(renderTranscript(history)) + history.add(ChatMessage(Role.ASSISTANT, text)) + events.onModelTurn(turn, text) + + val calls = extractToolCalls(text) + if (calls.isEmpty()) { + return Result(turn, StopReason.COMPLETED) + } + + // Terminal tool alone ends the loop; if co-emitted with real tools, run those first. + val realCalls = terminalTool?.let { tt -> calls.filterNot { it.name == tt } } ?: calls + terminalTool?.let { tt -> + val terminal = calls.firstOrNull { it.name == tt } + if (terminal != null && realCalls.isEmpty()) { + events.onFinalAnswer(turn, terminal.args["message"]?.toString().orEmpty()) + return Result(turn, StopReason.COMPLETED) + } + } + + // Stop if the model repeats identical tool calls beyond the tolerated count. + val signature = signatureOf(realCalls) + if (signature == previousSignature) { + consecutiveRepeats++ + if (consecutiveRepeats >= maxConsecutiveRepeats) { + events.onRepeatedToolCalls(turn) + return Result(turn, StopReason.REPEATED) + } + } else { + consecutiveRepeats = 0 + } + previousSignature = signature + + val results = executeTools(realCalls) + events.onToolResults(turn, realCalls, results) + history.add(ChatMessage(Role.USER, formatToolResults(realCalls, results))) + } + + events.onMaxIterationsReached(turn) + return Result(turn, StopReason.MAX_ITERATIONS) + } + + /** + * Builds a stable, order-sensitive fingerprint of a tool-call batch (name + args). + * @param calls the batch to fingerprint. + * @return the fingerprint string. + */ + private fun signatureOf(calls: List): String = + calls.joinToString("|") { call -> + call.name + "(" + call.args.toSortedMap().entries.joinToString(",") { "${it.key}=${it.value}" } + ")" + } + + /** + * Flattens the transcript into one prompt string, with no trailing "Assistant:" cue + * (the backend appends its own; a doubled cue makes local models repeat). + * @param history the conversation so far. + * @return the rendered prompt. + */ + fun renderTranscript(history: List): String { + val sb = StringBuilder() + for ((index, message) in history.withIndex()) { + if (index > 0) sb.append("\n\n") + when (message.role) { + Role.ASSISTANT -> sb.append("Assistant: ").append(message.content) + else -> sb.append(message.content) + } + } + return sb.toString() + } + + /** + * Renders tool results for feeding back into the next prompt, capping each body. + * @param calls the tool calls that ran. + * @param results their results, positionally aligned with [calls]. + * @return the formatted results block. + */ + fun formatToolResults(calls: List, results: List): String { + val sb = StringBuilder("Tool results:\n") + results.forEachIndexed { index, result -> + val name = calls.getOrNull(index)?.name ?: "tool" + val body = if (result.success) { + buildString { + append(result.message) + result.data?.takeIf { it.isNotBlank() }?.let { append("\n").append(it) } + } + } else { + buildString { + append("FAILED: ").append(result.message) + result.error_details?.takeIf { it.isNotBlank() }?.let { append("\n").append(it) } + } + } + sb.append("[").append(name).append("] ").append(truncate(body)).append("\n\n") + } + sb.append( + "Base your reply strictly on the tool result(s) above — report only what they actually say; " + + "do not invent, assume, or contradict them. " + ) + if (results.isNotEmpty() && results.all { it.success }) { + sb.append( + "The action succeeded. If this satisfies the user's request, you are DONE — reply with the " + + "\"respond\" tool briefly confirming what happened. Do NOT call another tool unless the " + + "request clearly needs a further step." + ) + } else { + sb.append("If the task is complete, give the user your final answer. Otherwise, call the next tool.") + } + return sb.toString() + } + + private fun truncate(text: String): String = + if (text.length <= toolOutputCharLimit) text + else text.take(toolOutputCharLimit) + "\n…[truncated ${text.length - toolOutputCharLimit} chars]" +} diff --git a/ai-assistant/src/main/kotlin/com/itsaky/androidide/plugins/aiassistant/tool/Executor.kt b/ai-assistant/src/main/kotlin/com/itsaky/androidide/plugins/aiassistant/tool/Executor.kt index 147435b0..f2516770 100644 --- a/ai-assistant/src/main/kotlin/com/itsaky/androidide/plugins/aiassistant/tool/Executor.kt +++ b/ai-assistant/src/main/kotlin/com/itsaky/androidide/plugins/aiassistant/tool/Executor.kt @@ -33,6 +33,7 @@ class Executor( fun requiredArgsForTool(toolName: String): List { return when (toolName) { "read_file" -> listOf("file_path") + "open_file" -> listOf("file_path") "list_files" -> emptyList() // directory is optional, defaults to "." "search_project" -> listOf("query") "create_file" -> listOf("file_path", "content") @@ -87,12 +88,14 @@ class Executor( return ToolResult.failure("Unknown function '$toolName'") } - // Normalize arg keys for read_file: if "path" is present but "file_path" is missing, remap + // Alias "path" → "file_path" for any tool that requires "file_path". val normalizedArgs = args.toMutableMap() - if (toolName == "read_file" && normalizedArgs.containsKey("path") && !normalizedArgs.containsKey("file_path")) { + if ("file_path" in requiredArgsForTool(toolName) && + normalizedArgs.containsKey("path") && !normalizedArgs.containsKey("file_path") + ) { normalizedArgs["path"]?.let { normalizedArgs["file_path"] = it } if (normalizedArgs.containsKey("file_path")) { - Log.d(TAG, "($executionMode): Remapped 'path' → 'file_path' for read_file tool") + Log.d(TAG, "($executionMode): Remapped 'path' → 'file_path' for $toolName tool") } } @@ -107,13 +110,7 @@ class Executor( return ToolResult.failure(message) } - for (key in handler.pathArgs) { - val raw = normalizedArgs[key]?.toString()?.trim() - if (!raw.isNullOrEmpty() && PathGuard.resolveWithin(raw) == null) { - Log.w(TAG, "($executionMode): '$toolName' arg '$key' escapes project root: $raw") - return ToolResult.failure("Path '$raw' is outside the project directory") - } - } + pathContainmentFailure(toolName, handler, normalizedArgs, executionMode)?.let { return it } // Check approval val approvalResponse = approvalManager.ensureApproved(toolName, handler, normalizedArgs) @@ -137,6 +134,32 @@ class Executor( Log.i(TAG, "($executionMode): Result: ${result.toResultMap()}") return result } + + /** + * Confines model-supplied path args to the project root; handlers that resolve + * paths themselves opt out via [ToolHandler.resolvesPathsInternally]. + * @param toolName the tool being dispatched (for logging). + * @param handler the tool's handler, source of [ToolHandler.pathArgs]. + * @param args the normalized call arguments. + * @param executionMode "Parallel"/"Sequential", for logging. + * @return a failure [ToolResult] for the first escaping arg, or null if all are safe. + */ + private fun pathContainmentFailure( + toolName: String, + handler: ToolHandler, + args: Map, + executionMode: String, + ): ToolResult? { + if (handler.resolvesPathsInternally) return null + for (key in handler.pathArgs) { + val raw = args[key]?.toString()?.trim() + if (raw.isNullOrEmpty()) continue + if (PathGuard.resolveWithin(raw) != null) continue + Log.w(TAG, "($executionMode): '$toolName' arg '$key' escapes project root: $raw") + return ToolResult.failure("Path '$raw' is outside the project directory") + } + return null + } } /** diff --git a/ai-assistant/src/main/kotlin/com/itsaky/androidide/plugins/aiassistant/tool/ToolCallExtractor.kt b/ai-assistant/src/main/kotlin/com/itsaky/androidide/plugins/aiassistant/tool/ToolCallExtractor.kt index 1471ab8e..d8b7f6e9 100644 --- a/ai-assistant/src/main/kotlin/com/itsaky/androidide/plugins/aiassistant/tool/ToolCallExtractor.kt +++ b/ai-assistant/src/main/kotlin/com/itsaky/androidide/plugins/aiassistant/tool/ToolCallExtractor.kt @@ -7,7 +7,6 @@ import org.json.JSONObject * Extracts tool calls from LLM responses using multiple strategies: * 1. Explicit XML tags: {"tool":"name",...} * 2. JSON blocks: {"tool":"name",...} - * 3. Implicit actions: Detects from natural language patterns * * Works with both cloud (Gemini) and local LLMs. */ @@ -32,11 +31,6 @@ class ToolCallExtractor { toolCalls.addAll(extractFromJsonObjects(text)) } - // Strategy 3: Implicit actions from natural language - if (toolCalls.isEmpty()) { - toolCalls.addAll(extractImplicitActions(text)) - } - Log.d(TAG, "Extracted ${toolCalls.size} tool calls from response (${text.length} chars)") // Warn if we found incomplete tool calls @@ -78,18 +72,32 @@ class ToolCallExtractor { val toolCalls = mutableListOf() var found = 0 - // Find JSON objects with balanced braces containing "tool" field + // Find JSON objects with balanced braces containing "tool" field. var i = 0 while (i < text.length) { if (text[i] == '{') { - // Try to extract a balanced JSON object + // Extract a balanced object, ignoring braces inside string values. var braceCount = 0 var j = i var hasToolField = false + var inString = false + var escaped = false while (j < text.length) { - if (text[j] == '{') braceCount++ - else if (text[j] == '}') braceCount-- + val c = text[j] + if (inString) { + when { + escaped -> escaped = false + c == '\\' -> escaped = true + c == '"' -> inString = false + } + } else { + when (c) { + '"' -> inString = true + '{' -> braceCount++ + '}' -> braceCount-- + } + } // Check if this substring contains "tool" if (!hasToolField && text.substring(i, minOf(j + 1, text.length)).contains("\"tool\"")) { @@ -98,7 +106,7 @@ class ToolCallExtractor { j++ - if (braceCount == 0) { + if (!inString && braceCount == 0) { // Found complete object if (hasToolField) { val jsonStr = text.substring(i, j) @@ -123,56 +131,6 @@ class ToolCallExtractor { return toolCalls } - /** - * Strategy 3: Extract implicit tool calls from natural language. - * Detects action keywords and converts to tool calls. - */ - private fun extractImplicitActions(text: String): List { - val toolCalls = mutableListOf() - val lowerText = text.lowercase() - - Log.d(TAG, "Strategy 3 (Implicit actions): Analyzing for action keywords") - - // Patterns for list_files - if (matchesPattern(lowerText, listOf("list", "show"), listOf("file", "directory", "folder"))) { - val directory = extractDirectory(text) ?: "." - toolCalls.add(ToolCall("list_files", mapOf("directory" to directory))) - Log.d(TAG, "Detected: list_files action") - } - - // Patterns for read_file - if (matchesPattern(lowerText, listOf("read", "open", "view", "show"), listOf("file"))) { - val path = extractFilePath(text) - if (path != null) { - toolCalls.add(ToolCall("read_file", mapOf("path" to path))) - Log.d(TAG, "Detected: read_file action for $path") - } - } - - // Patterns for search_project - if (matchesPattern(lowerText, listOf("search", "find", "grep"), listOf("file", "code", "project"))) { - val query = extractSearchQuery(text) - if (query != null) { - toolCalls.add(ToolCall("search_project", mapOf("query" to query))) - Log.d(TAG, "Detected: search_project action for query: $query") - } - } - - // Patterns for create_file - if (matchesPattern(lowerText, listOf("create", "write", "make"), listOf("file"))) { - // Requires more context, generally not auto-triggered - Log.d(TAG, "Detected: create_file action (requires confirmation)") - } - - // Patterns for run_app - if (matchesPattern(lowerText, listOf("run", "launch", "build", "start"), listOf("app", "application"))) { - toolCalls.add(ToolCall("run_app", emptyMap())) - Log.d(TAG, "Detected: run_app action") - } - - return toolCalls - } - /** * Parse tool JSON and extract tool name and arguments. */ @@ -200,113 +158,5 @@ class ToolCallExtractor { null } } - - /** - * Check if text matches action pattern (verb + object). - */ - private fun matchesPattern(text: String, verbs: List, objects: List): Boolean { - val hasVerb = verbs.any { text.contains(it) } - val hasObject = objects.any { text.contains(it) } - return hasVerb && hasObject - } - - /** - * Extract directory path from natural language. - * Looks for explicit paths or common directory names. - */ - private fun extractDirectory(text: String): String? { - val lowerText = text.lowercase() - - // Check for common project directories - val commonDirs = mapOf( - "src" to "src", - "source" to "src", - "source code" to "src", - "main" to "src/main", - "java" to "src/main/java", - "kotlin" to "src/main/kotlin", - "resources" to "src/main/resources", - "test" to "src/test", - "root" to ".", - "project" to ".", - "current" to "." - ) - - for ((keyword, dir) in commonDirs) { - if (lowerText.contains(keyword)) { - Log.d(TAG, "Detected directory from keyword '$keyword': $dir") - return dir - } - } - - // Look for patterns like "in src", "in ./src", "in /path/to/dir", etc. - val patterns = listOf( - Regex("""(?:in|from)\s+(?:the\s+)?["`']?([/.\-\w]+)["`']?"""), - Regex("""directory\s+(?:of\s+)?["`']?([/.\-\w]+)["`']?"""), - Regex("""folder\s+["`']?([/.\-\w]+)["`']?"""), - Regex("""path\s+["`']?([/.\-\w]+)["`']?""") - ) - - for (pattern in patterns) { - val match = pattern.find(text) - if (match != null) { - val dir = match.groupValues[1] - if (dir.isNotEmpty() && dir.length > 1 && !dir.contains("the")) { - Log.d(TAG, "Extracted directory from pattern: $dir") - return dir - } - } - } - - // Default to current directory if no specific directory mentioned - Log.d(TAG, "No specific directory found, defaulting to current directory (.)") - return null // Will default to "." in ListFilesHandler - } - - /** - * Extract file path from natural language. - */ - private fun extractFilePath(text: String): String? { - // Look for patterns like "MainActivity.kt", "src/main/MainActivity.kt", etc. - val patterns = listOf( - Regex("""["`']([^"`'\s]+\.kt)["`']"""), - Regex("""file\s+["`']?([^"`'\s]+\.kt)["`']?"""), - Regex("""read\s+["`']?([^"`'\s]+)["`']?""") - ) - - for (pattern in patterns) { - val match = pattern.find(text) - if (match != null) { - val path = match.groupValues[1] - if (path.isNotEmpty() && !path.contains("the")) { - return path - } - } - } - - return null - } - - /** - * Extract search query from natural language. - */ - private fun extractSearchQuery(text: String): String? { - val patterns = listOf( - Regex("""(?:search|find|grep)\s+(?:for\s+)?["`']([^"`']+)["`']"""), - Regex("""search\s+(?:for\s+)?([^\.?!]+)""") - ) - - for (pattern in patterns) { - val match = pattern.find(text) - if (match != null) { - val query = match.groupValues[1].trim() - if (query.isNotEmpty() && query.length > 2) { - return query - } - } - } - - return null - } } } diff --git a/ai-assistant/src/main/kotlin/com/itsaky/androidide/plugins/aiassistant/tool/ToolHandler.kt b/ai-assistant/src/main/kotlin/com/itsaky/androidide/plugins/aiassistant/tool/ToolHandler.kt index 6e85aef3..1c53676d 100644 --- a/ai-assistant/src/main/kotlin/com/itsaky/androidide/plugins/aiassistant/tool/ToolHandler.kt +++ b/ai-assistant/src/main/kotlin/com/itsaky/androidide/plugins/aiassistant/tool/ToolHandler.kt @@ -37,4 +37,7 @@ interface ToolHandler { */ val pathArgs: List get() = emptyList() + + val resolvesPathsInternally: Boolean + get() = false } diff --git a/ai-assistant/src/main/kotlin/com/itsaky/androidide/plugins/aiassistant/tool/handlers/ListFilesHandler.kt b/ai-assistant/src/main/kotlin/com/itsaky/androidide/plugins/aiassistant/tool/handlers/ListFilesHandler.kt index 9e6e24b1..e83c518b 100644 --- a/ai-assistant/src/main/kotlin/com/itsaky/androidide/plugins/aiassistant/tool/handlers/ListFilesHandler.kt +++ b/ai-assistant/src/main/kotlin/com/itsaky/androidide/plugins/aiassistant/tool/handlers/ListFilesHandler.kt @@ -15,40 +15,29 @@ class ListFilesHandler( override val toolName = "list_files" override val description = "List files and directories in a given path" override val requiresApproval = false + override val pathArgs = listOf("directory") + // Resolved internally (below) to rescue slash-prefixed paths; opt out of the + // Executor pre-guard that would reject "/app/src" first. + override val resolvesPathsInternally = true override suspend fun execute(args: Map): ToolResult { - var directory = args["directory"]?.toString()?.trim()?.takeIf { it.isNotBlank() } - - // Get project root for containment check - val projectRoot = System.getProperty("project.dir") - ?: System.getProperty("user.dir") - ?: "/storage/emulated/0/AndroidIDEProjects" - val projectRootCanonical = File(projectRoot).canonicalPath - - // If no directory specified, use project root - if (directory.isNullOrBlank()) { - directory = projectRoot - Log.d("ListFilesHandler", "No directory specified, using project root: $directory") - } + val directory = args["directory"]?.toString()?.trim()?.takeIf { it.isNotBlank() } - Log.d("ListFilesHandler", "Listing files in directory: $directory") + Log.d("ListFilesHandler", "Listing files in directory: ${directory ?: ""}") return try { - // Resolve path against project root if relative - val dir = if (directory.startsWith("/")) { - File(directory).absoluteFile + // Resolve/containment-check via PathGuard; blank defaults to the root. + // A slash-prefixed relative dir ("/app/src") escapes the root, so retry + // it as relative — matching Read/OpenFileHandler. + val dir = if (directory == null) { + File(PathGuard.projectRoot()) } else { - File(projectRoot, directory).absoluteFile + PathGuard.resolveWithin(directory) + ?: PathGuard.resolveWithin(directory.removePrefix("/")) + ?: return ToolResult.failure("Directory path must be within project directory") } Log.d("ListFilesHandler", "Absolute path: ${dir.absolutePath}") - // Security: Verify directory is within project root - val dirCanonical = dir.canonicalPath - if (!dirCanonical.startsWith(projectRootCanonical + File.separator) && dirCanonical != projectRootCanonical) { - Log.e("ListFilesHandler", "Path escape attempt: $dirCanonical is outside project root $projectRootCanonical") - return ToolResult.failure("Directory path must be within project directory") - } - if (!dir.exists()) { Log.w("ListFilesHandler", "Directory does not exist: ${dir.absolutePath}") return ToolResult.failure("Directory does not exist: ${dir.absolutePath}") @@ -112,11 +101,4 @@ class ListFilesHandler( else -> "${bytes / (1024 * 1024 * 1024)} GB" } } - - private fun findDefaultDirectory(): String { - // Return project root only for security - return System.getProperty("project.dir") - ?: System.getProperty("user.dir") - ?: "/storage/emulated/0/AndroidIDEProjects" - } } diff --git a/ai-assistant/src/main/kotlin/com/itsaky/androidide/plugins/aiassistant/tool/handlers/OpenFileHandler.kt b/ai-assistant/src/main/kotlin/com/itsaky/androidide/plugins/aiassistant/tool/handlers/OpenFileHandler.kt index 1ffaeb08..3aeffb12 100644 --- a/ai-assistant/src/main/kotlin/com/itsaky/androidide/plugins/aiassistant/tool/handlers/OpenFileHandler.kt +++ b/ai-assistant/src/main/kotlin/com/itsaky/androidide/plugins/aiassistant/tool/handlers/OpenFileHandler.kt @@ -5,17 +5,24 @@ import com.itsaky.androidide.plugins.PluginContext import com.itsaky.androidide.plugins.aiassistant.models.ToolResult import com.itsaky.androidide.plugins.aiassistant.tool.ToolHandler import com.itsaky.androidide.plugins.services.IdeEditorService +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext /** * Handler for opening files in the IDE editor. + * + * @param mainDispatcher dispatcher for editor-UI calls; overridden in unit tests. */ class OpenFileHandler( - private val pluginContext: PluginContext + private val pluginContext: PluginContext, + private val mainDispatcher: CoroutineDispatcher = Dispatchers.Main ) : ToolHandler { override val toolName = "open_file" override val description = "Open a file in the IDE editor" override val requiresApproval = false override val pathArgs = listOf("file_path") + override val resolvesPathsInternally = true override suspend fun execute(args: Map): ToolResult { val filePath = args["file_path"]?.toString()?.trim() @@ -26,14 +33,26 @@ class OpenFileHandler( Log.d("OpenFileHandler", "Opening file: $filePath") return try { - val file = PathGuard.resolveWithin(filePath) - ?: return ToolResult.failure("File path must be within project directory") - if (!file.exists()) { - Log.w("OpenFileHandler", "File does not exist: $filePath") - return ToolResult.failure( - "File not found", - "File does not exist: $filePath" - ) + val file = when (val resolution = PathGuard.resolve(filePath)) { + is PathGuard.Resolution.Resolved -> { + Log.d("OpenFileHandler", "Resolved '$filePath' -> ${resolution.file.path}") + resolution.file + } + is PathGuard.Resolution.Ambiguous -> { + val root = java.io.File(PathGuard.projectRoot()) + return ToolResult.failure( + "Multiple files named '${resolution.baseName}' — specify a path", + resolution.matches.joinToString("\n") { it.relativeToOrSelf(root).path } + ) + } + PathGuard.Resolution.Escaped -> { + Log.w("OpenFileHandler", "Path outside project and no match found: $filePath") + return ToolResult.failure("File path must be within project directory") + } + PathGuard.Resolution.NotFound -> { + Log.w("OpenFileHandler", "File does not exist: $filePath") + return ToolResult.failure("File not found", "File does not exist: $filePath") + } } if (!file.isFile) { @@ -53,7 +72,8 @@ class OpenFileHandler( ) } - val success = editorService.openFile(file) + // Opening a tab touches UI; execute() runs on Dispatchers.IO. + val success = withContext(mainDispatcher) { editorService.openFile(file) } if (success) { Log.d("OpenFileHandler", "File opened successfully: $filePath") ToolResult.success( @@ -69,10 +89,7 @@ class OpenFileHandler( } } catch (e: Exception) { Log.e("OpenFileHandler", "Error opening file", e) - ToolResult.failure( - "Error opening file", - "${e.message ?: "Unknown error"}\n\n${e.stackTraceToString()}" - ) + ToolResult.failure("Error opening file", e.message ?: "Unknown error") } } } diff --git a/ai-assistant/src/main/kotlin/com/itsaky/androidide/plugins/aiassistant/tool/handlers/PathGuard.kt b/ai-assistant/src/main/kotlin/com/itsaky/androidide/plugins/aiassistant/tool/handlers/PathGuard.kt index 6b5c3179..c62c4b7b 100644 --- a/ai-assistant/src/main/kotlin/com/itsaky/androidide/plugins/aiassistant/tool/handlers/PathGuard.kt +++ b/ai-assistant/src/main/kotlin/com/itsaky/androidide/plugins/aiassistant/tool/handlers/PathGuard.kt @@ -1,57 +1,170 @@ package com.itsaky.androidide.plugins.aiassistant.tool.handlers import android.util.Log -import com.itsaky.androidide.plugins.aiassistant.AiAssistantPlugin -import com.itsaky.androidide.plugins.services.IdeProjectService import java.io.File +import java.nio.file.Files /** - * Shared path-containment guard for filesystem tool handlers. - * - * The LLM-driven tools resolve paths supplied by the model, so every handler - * that touches the filesystem must confine those paths to the current project - * root to prevent a prompt-injected model from reading or writing arbitrary - * files. Previously this check lived (duplicated) only in CreateFileHandler and - * ReadFileHandler; it is now centralized here and applied to every handler. + * Shared path-containment guard for filesystem tool handlers: confines every + * model-supplied path to the current project root. The root comes from + * [projectRootProvider] (host-wired), then a legacy system property, then [DEFAULT_ROOT]. */ object PathGuard { private const val TAG = "PathGuard" + private const val DEFAULT_ROOT = "/storage/emulated/0/AndroidIDEProjects" + + /** Host-backed supplier of the current project root; queried on every resolution. */ + @Volatile + private var projectRootProvider: (() -> String?)? = null + + /** Test-only override; when non-null it wins over everything else. */ + @Volatile + private var projectRootOverride: String? = null + + /** + * Installs (or clears, with `null`) the host-backed project-root supplier. + * @param provider supplier of the project root, or null to clear. + */ + fun setProjectRootProvider(provider: (() -> String?)?) { + projectRootProvider = provider + } /** - * Resolve the active project root, preferring the IDE's current project. - * @return absolute project-root path, or null when none can be determined. + * Forces a specific root, or clears it with `null`. Tests only. + * @param root the root to force, or null to clear. */ - fun projectRoot(): String? { - // Prefer the IDE-provided current project root when the service is reachable. - val ideRoot = runCatching { - val service: IdeProjectService? = - AiAssistantPlugin.getContext()?.services?.get(IdeProjectService::class.java) - service?.getCurrentProject()?.rootDir?.absolutePath - }.getOrNull() - if (ideRoot != null) return ideRoot - // Fall back to JVM system properties; no hardcoded root, so an unknown root fails safe. - return System.getProperty("project.dir") ?: System.getProperty("user.dir") + fun setProjectRootForTesting(root: String?) { + projectRootOverride = root } /** - * Resolve [path] against the project root and verify it stays inside it. - * @param path model-supplied path, absolute or project-relative. - * @return contained [File], or null when the root is unknown or [path] escapes. + * Resolves the active project root (best-effort); `user.dir` is never consulted + * (it is "/" on Android) so [isValidRoot] can reject it and the guard fails closed. + * @return the resolved root path (not guaranteed valid). + */ + fun projectRoot(): String = + projectRootOverride + ?: projectRootProvider?.invoke()?.takeIf { it.isNotBlank() } + ?: System.getProperty("project.dir") + ?: DEFAULT_ROOT + + /** + * A usable root is a non-blank, existing directory that isn't the filesystem root "/" + * ("/" means no project is open, and would confine nothing). + * @param root candidate root. + * @return true if [root] is a usable project root. + */ + private fun isValidRoot(root: File): Boolean { + val canonical = try { root.canonicalPath } catch (e: Exception) { return false } + return canonical.isNotBlank() && canonical != File.separator && root.isDirectory + } + + /** + * Resolves [path] against the project root, verifying the canonical result is inside it. + * @param path a relative or absolute path from the model. + * @return the resolved [File] when contained, or null when it escapes or there is no valid root. */ fun resolveWithin(path: String): File? { - val rootPath = projectRoot() - if (rootPath == null) { - Log.e(TAG, "Denying path access: project root could not be determined") + val rootFile = File(projectRoot()) + if (!isValidRoot(rootFile)) { + Log.e(TAG, "No valid project root ('${rootFile.path}'); rejecting path: $path") return null } - val root = File(rootPath).canonicalPath - val file = if (path.startsWith("/")) File(path) else File(rootPath, path) + val root = rootFile.canonicalPath + val file = if (path.startsWith("/")) File(path) else File(rootFile, path) val canonical = file.canonicalPath - val contained = canonical == root || canonical.startsWith(root + File.separator) + + val rootWithSep = if (root.endsWith(File.separator)) root else root + File.separator + val contained = canonical == root || canonical.startsWith(rootWithSep) if (!contained) { Log.e(TAG, "Path escape attempt: $canonical is outside project root $root") } return if (contained) file else null } + + /** Directories skipped when searching for a file by name — large/generated/noise. */ + private val SKIP_DIRS = setOf("build", ".git", ".gradle", ".idea", "node_modules", ".cxx") + + /** + * Finds in-root files whose name equals [fileName] (case-insensitive), skipping + * generated/hidden dirs and symlinks, so a bare name resolves to a real path. + * @param fileName the name to match (its basename is used). + * @param limit max results. + * @return matching files (possibly empty); more than one means ambiguous. + */ + fun findByName(fileName: String, limit: Int = 20): List { + val target = baseNameOf(fileName).trim() + if (target.isEmpty()) return emptyList() + + val root = File(projectRoot()) + if (!isValidRoot(root)) return emptyList() + val rootWithSep = root.canonicalPath.let { if (it.endsWith(File.separator)) it else it + File.separator } + + return root.walkTopDown() + // Don't descend symlinked dirs; walkTopDown matches by name and could escape the root. + .onEnter { dir -> + (dir == root || (dir.name !in SKIP_DIRS && !dir.name.startsWith("."))) && + (dir == root || !Files.isSymbolicLink(dir.toPath())) + } + .filter { it.isFile && it.name.equals(target, ignoreCase = true) } + // Re-verify containment so a symlinked file resolving outside the root is dropped. + .filter { it.canonicalPath.startsWith(rootWithSep) } + .take(limit) + .toList() + } + + /** + * Returns the last path segment, tolerating both '/' and '\' separators. + * @param path the path. + * @return the basename. + */ + private fun baseNameOf(path: String): String = + path.substringAfterLast('/').substringAfterLast('\\') + + /** Outcome of [resolve]; callers match on it to act or to explain the miss. */ + sealed interface Resolution { + /** + * The path resolved to an existing in-root entry. + * @property file the resolved file or directory. + */ + data class Resolved(val file: File) : Resolution + + /** + * A bare name matched several files; ask the user to disambiguate. + * @property baseName the searched name. + * @property matches the candidate files. + */ + data class Ambiguous(val baseName: String, val matches: List) : Resolution + + /** No existing match, but the path stayed in-root — it simply doesn't exist. */ + object NotFound : Resolution + + /** The path had no in-root interpretation at all (a containment escape). */ + object Escaped : Resolution + } + + /** + * Resolves a model-supplied path to a project entry, enforcing root containment. + * Tries in-root, then slash-stripped relative, then a project-wide basename search. + * @param path a relative, absolute, or bare file name from the model. + * @return the matching [Resolution]. + */ + fun resolve(path: String): Resolution { + val primary = resolveWithin(path) + val relative = if (path.startsWith("/")) resolveWithin(path.removePrefix("/")) else null + + (primary?.takeIf { it.exists() } ?: relative?.takeIf { it.exists() })?.let { + return Resolution.Resolved(it) + } + + val matches = findByName(path) + when (matches.size) { + 1 -> return Resolution.Resolved(matches[0]) + 0 -> {} + else -> return Resolution.Ambiguous(baseNameOf(path), matches) + } + + return if (primary == null && relative == null) Resolution.Escaped else Resolution.NotFound + } } diff --git a/ai-assistant/src/main/kotlin/com/itsaky/androidide/plugins/aiassistant/tool/handlers/ReadFileHandler.kt b/ai-assistant/src/main/kotlin/com/itsaky/androidide/plugins/aiassistant/tool/handlers/ReadFileHandler.kt index b4502d99..11ed7947 100644 --- a/ai-assistant/src/main/kotlin/com/itsaky/androidide/plugins/aiassistant/tool/handlers/ReadFileHandler.kt +++ b/ai-assistant/src/main/kotlin/com/itsaky/androidide/plugins/aiassistant/tool/handlers/ReadFileHandler.kt @@ -15,6 +15,7 @@ class ReadFileHandler( override val description = "Read the contents of a file" override val requiresApproval = false override val pathArgs = listOf("file_path", "path") + override val resolvesPathsInternally = true override suspend fun execute(args: Map): ToolResult { // Accept both "file_path" (standardized) and "path" (legacy LLM responses) @@ -24,26 +25,35 @@ class ReadFileHandler( } return try { - // Security: resolve against the project root and reject any escape. - val file = PathGuard.resolveWithin(filePath) - ?: return ToolResult.failure("File path must be within project directory") + val file = when (val resolution = PathGuard.resolve(filePath)) { + is PathGuard.Resolution.Resolved -> resolution.file + is PathGuard.Resolution.Ambiguous -> { + val root = java.io.File(PathGuard.projectRoot()) + return ToolResult.failure( + "Multiple files named '${resolution.baseName}' — specify a path", + resolution.matches.joinToString("\n") { it.relativeToOrSelf(root).path } + ) + } + PathGuard.Resolution.Escaped -> + return ToolResult.failure("File path must be within project directory") + PathGuard.Resolution.NotFound -> + return ToolResult.failure("File does not exist: $filePath") + } - if (!file.exists()) { - ToolResult.failure("File does not exist: $filePath") - } else if (!file.isFile) { - ToolResult.failure("Path is not a file: $filePath") - } else if (!file.canRead()) { - ToolResult.failure("Cannot read file: $filePath") - } else { - val content = file.readText() - ToolResult.success( - message = "Read ${content.length} characters from $filePath", - data = content - ) + when { + !file.isFile -> ToolResult.failure("Path is not a file: $filePath") + !file.canRead() -> ToolResult.failure("Cannot read file: $filePath") + else -> { + val content = file.readText() + ToolResult.success( + message = "Read ${content.length} characters from $filePath", + data = content + ) + } } } catch (e: Exception) { Log.e("ReadFileHandler", "Error reading file", e) - ToolResult.failure("Error reading file: ${e.message}", e.stackTraceToString()) + ToolResult.failure("Error reading file: ${e.message}") } } } diff --git a/ai-assistant/src/main/kotlin/com/itsaky/androidide/plugins/aiassistant/viewmodel/ChatViewModel.kt b/ai-assistant/src/main/kotlin/com/itsaky/androidide/plugins/aiassistant/viewmodel/ChatViewModel.kt index b057946c..00c0be31 100644 --- a/ai-assistant/src/main/kotlin/com/itsaky/androidide/plugins/aiassistant/viewmodel/ChatViewModel.kt +++ b/ai-assistant/src/main/kotlin/com/itsaky/androidide/plugins/aiassistant/viewmodel/ChatViewModel.kt @@ -3,11 +3,14 @@ package com.itsaky.androidide.plugins.aiassistant.viewmodel import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.itsaky.androidide.plugins.PluginContext +import com.itsaky.androidide.plugins.aiassistant.R import com.itsaky.androidide.plugins.aiassistant.models.AgentState import com.itsaky.androidide.plugins.aiassistant.models.ChatMessage import com.itsaky.androidide.plugins.aiassistant.models.ChatSession import com.itsaky.androidide.plugins.aiassistant.models.MessageStatus import com.itsaky.androidide.plugins.aiassistant.models.Sender +import com.itsaky.androidide.plugins.aiassistant.models.ToolResult +import com.itsaky.androidide.plugins.aiassistant.tool.AgentLoop import com.itsaky.androidide.plugins.aiassistant.tool.Executor import com.itsaky.androidide.plugins.aiassistant.tool.ToolApprovalManager import com.itsaky.androidide.plugins.aiassistant.tool.ToolCall @@ -29,6 +32,8 @@ import com.itsaky.androidide.plugins.aiassistant.data.ChatStorageManager import com.itsaky.androidide.plugins.aiassistant.utils.ToolExecutionTracker import com.itsaky.androidide.plugins.services.LlmInferenceService import com.itsaky.androidide.plugins.services.SharedServices +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.CompletableDeferred import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job import kotlinx.coroutines.delay @@ -40,6 +45,7 @@ import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.isActive import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext import org.json.JSONObject import java.io.File import java.util.UUID @@ -51,6 +57,37 @@ class ChatViewModel( private val getContext: () -> PluginContext? ) : ViewModel() { + companion object { + /** Terminal tool: shared by [agentLoop] (stops on it) and [runModelTurn] (renders its message). */ + const val RESPOND_TOOL = "respond" + + /** [LlmConfig.extraParams] key for the local-backend GBNF; must match ai-core's `LocalLlmBackend.EXTRA_PARAM_GRAMMAR`. */ + private const val EXTRA_PARAM_GRAMMAR = "grammar" + } + + /** + * Builds the local-backend GBNF forcing one well-formed ``. The `tool` + * alternatives come from the registered handlers (+ [RESPOND_TOOL]) so the token + * mask can't drift; control chars are excluded so `org.json` accepts the strings. + * @return the GBNF grammar string. + */ + private fun buildLocalToolCallGrammar(): String { + val toolNames = (toolRouter.getAllHandlers().map { it.toolName } + RESPOND_TOOL).distinct() + val toolAlternatives = toolNames.joinToString(" | ") { "\"~$it~\"" } + return """ + root ::= "{~tool~:" tool ",~args~:" args "}" + tool ::= $toolAlternatives + args ::= "{}" | "{" pair ("," pair)* "}" + pair ::= "~" key "~:~" val "~" + key ::= [a-z_]+ + val ::= char* + char ::= [^~\\\x00-\x1F] | "\\" [~\\/bfnrt] + """.trimIndent().replace("~", "\\\"") + } + + /** Local-backend GBNF, built once from the registered handlers. */ + private val localToolCallGrammar: String by lazy { buildLocalToolCallGrammar() } + private fun getLlmService(): LlmInferenceService? { return try { SharedServices.get(LlmInferenceService::class.java) @@ -112,8 +149,20 @@ class ChatViewModel( private val approvalManager = ToolApprovalManager() private val toolRouter: ToolRouter private val executor: Executor + private val agentLoop = AgentLoop(terminalTool = RESPOND_TOOL) val toolExecutionTracker = ToolExecutionTracker() + /** The in-flight agent run (streaming + tool loop), so it can be cancelled. */ + private var generationJob: Job? = null + private val generationEpoch = java.util.concurrent.atomic.AtomicInteger(0) + + /** True while a generation is admitted and its coroutine has not yet unwound; gates re-entry. */ + private val isGenerating = java.util.concurrent.atomic.AtomicBoolean(false) + + /** Whether the current run's most recent tool batch failed; reset per run. */ + @Volatile + private var lastToolFailedThisRun = false + private val _pendingApprovalRequest = MutableStateFlow(null) val pendingApprovalRequest: StateFlow = _pendingApprovalRequest.asStateFlow() @@ -301,6 +350,25 @@ class ChatViewModel( - After each file modification, verify the build compiles - Generate apps that actually run and work as described + RULES: + - Never fabricate tool output. Emit a tool call, then wait for the real result before continuing. + - Never write "User:", "Assistant:", or "Tool results:" — the system supplies those. + - Paths are relative to the project root and must be complete. If you don't know a file's exact path, find it with search_project or list_files first, then act on the real path — don't guess. + - For plain chat (e.g. "Hi"), just reply briefly with no tool call. When the task is done, give a short summary with no tool call. + + TOOL CALL FORMAT — to run a tool, emit a single line in EXACTLY this format and nothing after it: + {"tool":"TOOL_NAME","args":{"arg":"value"}} + Do NOT describe the action in prose (e.g. "Okay, I'll open the file…") — narrating does nothing. + The tool only runs when you emit the line itself. + + FORMAT EXAMPLES (the tool call is the entire reply): + Open a file once you know its path: + {"tool":"open_file","args":{"file_path":"app/src/main/java/com/example/app/MainActivity.java"}} + Find a file by name: + {"tool":"search_project","args":{"query":"MainActivity"}} + List a directory: + {"tool":"list_files","args":{"directory":"app/src/main"}} + WORKFLOW: 1. Understand the user's request 2. List files to understand the project structure @@ -309,8 +377,6 @@ class ChatViewModel( 5. Sync gradle and verify compilation 6. Run the app to confirm it works 7. Report success and what was built - - You have full access to tools - use them continuously throughout the workflow. """.trimIndent() android.util.Log.d("ChatViewModel", "Using Gemini system prompt (high autonomy mode) with ${toolRouter.getAllHandlers().size} tools") @@ -326,38 +392,24 @@ class ChatViewModel( } val prompt = """ - You are a helpful coding assistant integrated into AndroidIDE. Help the user build Android apps step-by-step. + You are a coding assistant inside AndroidIDE. - CRITICAL: You MUST use tools for ANY action-related request. Do NOT just describe what you would do. + Rules: + - Reply with exactly ONE tool call, nothing else. + - Use a file/project tool only when the user asks about files, code, or the project; for a greeting, small talk, or a question you can answer, use "respond". + - Never invent tool output or claim an action you didn't perform via a tool. After a tool call, stop; the real result returns next turn. + - "respond" must carry a "message" — your reply or final answer. + - File arguments accept a bare name (e.g. "MainActivity.java"); the project is searched. Don't invent deep paths. - AVAILABLE TOOLS: + Tools: $toolDescriptions + - respond: Send the user a message or your final answer. - TOOL CALLING RULES: - 1. When user asks to perform an action (list, read, search, create, update, run), IMMEDIATELY call the tool - 2. Do NOT explain or apologize - just execute the tool call - 3. Always provide the tool call in this EXACT format: - {"tool":"TOOL_NAME","args":{"param1":"value1"}} - 4. Execute tools BEFORE saying anything else - - STEP-BY-STEP WORKFLOW: - 1. List files to understand the project - 2. Read existing files to know what to change - 3. Create or update one file at a time - 4. After each file, ask the user what to do next - 5. Add dependencies when needed - 6. Sync gradle to check for errors - 7. Run the app to test it - 8. Ask for feedback and iterate - - EXAMPLES: - User: "list files in src" - {"tool":"list_files","args":{"directory":"src"}} - - User: "read MainActivity.kt" - {"tool":"read_file","args":{"file_path":"MainActivity.kt"}} - - After each tool call, analyze the result and ask: "What would you like to do next?" + Examples (pick the tool that matches; don't copy verbatim): + Greeting / question you can answer -> respond: + {"tool":"respond","args":{"message":"Hi! What would you like to build?"}} + Open a file -> open_file: + {"tool":"open_file","args":{"file_path":"MainActivity.java"}} """.trimIndent() android.util.Log.d("ChatViewModel", "Using Local LLM system prompt (guided mode) with ${toolRouter.getAllHandlers().size} tools") @@ -365,51 +417,51 @@ class ChatViewModel( } /** - * Parse tool calls from text using multi-strategy extraction. - * Tries: XML tags → JSON objects → Implicit actions + * Executes a batch of tool calls, renders each result as a TOOL message, and + * returns the results for the [agentLoop] to feed back; leaves [AgentState.Idle] + * to the loop. + * @param toolCalls the calls to execute. + * @return the results, positionally aligned with [toolCalls]. */ - private fun parseToolCalls(text: String): List { - return ToolCallExtractor.extractToolCalls(text) - } - - /** - * Execute tool calls and add results to chat. - */ - private suspend fun executeToolCalls(toolCalls: List) { - if (toolCalls.isEmpty()) return + private suspend fun executeToolCalls(toolCalls: List): List { + if (toolCalls.isEmpty()) return emptyList() val executingState = AgentState.Executing( currentStepIndex = 0, totalSteps = toolCalls.size, description = toolCalls.first().name ) - _agentState.value = executingState + withContext(Dispatchers.Main) { _agentState.value = executingState } startStateTimer(executingState) val results = executor.execute(toolCalls) + // Record whether this batch's last tool failed (read by runModelTurn). + lastToolFailedThisRun = results.lastOrNull()?.success == false + // Add tool results as messages - results.forEachIndexed { index, result -> - val toolCall = toolCalls[index] - val resultText = if (result.success) { - "${toolCall.name}: ${result.message}\n${result.data ?: ""}" - } else { - "${toolCall.name} failed: ${result.message}\n${result.error_details ?: ""}" + withContext(Dispatchers.Main) { + results.forEachIndexed { index, result -> + val toolCall = toolCalls[index] + val resultText = if (result.success) { + "${toolCall.name}: ${result.message}\n${result.data ?: ""}" + } else { + "${toolCall.name} failed: ${result.message}\n${result.error_details ?: ""}" + } + val resultMessage = ChatMessage( + id = UUID.randomUUID().toString(), + text = resultText, + sender = Sender.TOOL, + status = if (result.success) MessageStatus.SENT else MessageStatus.ERROR + ) + android.util.Log.d("ChatViewModel", "Adding tool result message: $resultText") + _messages.value = _messages.value + resultMessage + syncMessageToSession(resultMessage) } - val resultMessage = ChatMessage( - id = UUID.randomUUID().toString(), - text = resultText, - sender = Sender.TOOL, - status = if (result.success) MessageStatus.SENT else MessageStatus.ERROR - ) - android.util.Log.d("ChatViewModel", "Adding tool result message: $resultText") - _messages.value = _messages.value + resultMessage - android.util.Log.d("ChatViewModel", "Total messages after tool result: ${_messages.value.size}") - syncMessageToSession(resultMessage) } stopStateTimer() - _agentState.value = AgentState.Idle + return results } /** @@ -509,168 +561,264 @@ class ChatViewModel( return } + // Reject re-entry while a generation is still in flight. + if (!isGenerating.compareAndSet(false, true)) { + android.util.Log.d("ChatViewModel", "sendMessage: generation already in progress; ignoring") + return + } + android.util.Log.d("ChatViewModel", "sendMessage: Starting message processing") - viewModelScope.launch(Dispatchers.IO) { + // Reset per-run tool-failure tracking. + lastToolFailedThisRun = false + val epoch = generationEpoch.incrementAndGet() + generationJob = viewModelScope.launch(Dispatchers.IO) { try { - // Add user message + // Add user message to the UI. val userChatMessage = ChatMessage( id = UUID.randomUUID().toString(), text = userMessage, sender = Sender.USER, status = MessageStatus.SENT ) - _messages.value = _messages.value + userChatMessage - android.util.Log.d("ChatViewModel", "sendMessage: Added user message, total messages=${_messages.value.size}") - syncMessageToSession(userChatMessage) - - // Add empty agent message that will be updated with streaming tokens - val agentMessageId = UUID.randomUUID().toString() - val agentMessage = ChatMessage( - id = agentMessageId, - text = "", - sender = Sender.AGENT, - status = MessageStatus.SENT // SENT so text is visible immediately - ) - _messages.value = _messages.value + agentMessage - syncMessageToSession(agentMessage) - - // Set processing state - _agentState.value = AgentState.Processing("Generating...") - - // Record start time - val startTime = System.currentTimeMillis() + withContext(Dispatchers.Main) { + _messages.value = _messages.value + userChatMessage + syncMessageToSession(userChatMessage) + _agentState.value = AgentState.Processing(str(R.string.msg_generating)) + } - // Create LLM config val config = LlmInferenceService.LlmConfig(currentBackendId).apply { temperature = 0.7f - maxTokens = 4096 // Increased from 2048 to ensure complete tool calls are generated + maxTokens = 4096 // headroom for complete tool calls systemPrompt = buildSystemPrompt() + // Local backend constrains generation to this grammar; cloud ignores it. + extraParams = mapOf(EXTRA_PARAM_GRAMMAR to localToolCallGrammar) } - // Build message with context if any files are selected val messageWithContext = buildString { append(userMessage) - val context = buildContextString() - if (context.isNotEmpty()) { - append(context) - } + append(buildContextString()) } - - // Add user message to conversation history for LLM context - val userHistoryMessage = LlmInferenceService.ChatMessage( - LlmInferenceService.ChatMessage.Role.USER, - messageWithContext + val history = _history.value.toMutableList() + history.add( + LlmInferenceService.ChatMessage( + LlmInferenceService.ChatMessage.Role.USER, + messageWithContext + ) ) - _history.value = _history.value + userHistoryMessage - android.util.Log.d("ChatViewModel", "Added user to history. Total history length: ${_history.value.size}") - - // Accumulated response text - val responseBuilder = StringBuilder() - - // Use streaming API with callback - llmService.generateStreaming(messageWithContext, config, object : LlmInferenceService.StreamCallback { - override fun onToken(token: String) { - viewModelScope.launch(Dispatchers.Main) { - // Accumulate token - responseBuilder.append(token) - - // Update the message with new text using map() to trigger DiffUtil - val updatedMessage = ChatMessage( - id = agentMessageId, - text = responseBuilder.toString(), - sender = Sender.AGENT, - status = MessageStatus.SENT - ) - _messages.value = _messages.value.map { msg -> - if (msg.id == agentMessageId) { - updatedMessage - } else { - msg - } - } - - // Also update current session's message - syncMessageToSession(updatedMessage) - } - } - override fun onComplete(response: LlmInferenceService.LlmResponse) { - viewModelScope.launch(Dispatchers.IO) { - val durationMs = System.currentTimeMillis() - startTime - val finalText = response.text - - // Mark message as completed with final text - launch(Dispatchers.Main) { - val updatedMessage = ChatMessage( - id = agentMessageId, - text = finalText, - sender = Sender.AGENT, - status = MessageStatus.COMPLETED, - durationMs = durationMs + try { + agentLoop.run( + history = history, + generate = { prompt -> + withContext(Dispatchers.Main) { + _agentState.value = AgentState.Processing(str(R.string.msg_generating)) + } + runModelTurn(llmService, prompt, config, epoch) + }, + executeTools = { calls -> executeToolCalls(calls) }, + events = object : AgentLoop.Events { + override suspend fun onMaxIterationsReached(turns: Int) { + addSystemMessage( + str(R.string.agent_max_steps_reached, turns), + MessageStatus.SENT ) - _messages.value = _messages.value.map { msg -> - if (msg.id == agentMessageId) { - updatedMessage - } else { - msg - } - } - - syncMessageToSession(updatedMessage) } - // Add assistant response to conversation history for LLM context - val assistantHistoryMessage = LlmInferenceService.ChatMessage( - LlmInferenceService.ChatMessage.Role.ASSISTANT, - finalText - ) - _history.value = _history.value + assistantHistoryMessage - android.util.Log.d("ChatViewModel", "Added assistant to history. Total history length: ${_history.value.size}") - - // Parse and execute tool calls if any - val toolCalls = parseToolCalls(finalText) - if (toolCalls.isNotEmpty()) { - executeToolCalls(toolCalls) - } else { - launch(Dispatchers.Main) { - _agentState.value = AgentState.Idle - } + override suspend fun onRepeatedToolCalls(turns: Int) { + addSystemMessage( + str(R.string.agent_repeated_calls), + MessageStatus.SENT + ) } } + ) + } finally { + // Persist history only if this run wasn't superseded (epoch bumped). + if (generationEpoch.get() == epoch) { + _history.value = history.toList() } + stopStateTimer() + } - override fun onError(error: String) { - viewModelScope.launch(Dispatchers.Main) { - val durationMs = System.currentTimeMillis() - startTime - - // Remove the agent message - _messages.value = _messages.value.filter { it.id != agentMessageId } - - // Add error message - _agentState.value = AgentState.Error(error) - val errorMessage = ChatMessage( - id = UUID.randomUUID().toString(), - text = error, - sender = Sender.SYSTEM, - status = MessageStatus.ERROR, - durationMs = durationMs - ) - _messages.value = _messages.value + errorMessage - syncMessageToSession(errorMessage) + withContext(Dispatchers.Main) { _agentState.value = AgentState.Idle } + } catch (ce: CancellationException) { + stopStateTimer() + throw ce + } catch (e: Exception) { + android.util.Log.e("ChatViewModel", "sendMessage failed", e) + stopStateTimer() + _agentState.value = AgentState.Error(str(R.string.state_error, e.message)) + addSystemMessage(str(R.string.state_error, e.message), MessageStatus.ERROR) + } finally { + // Allow re-entry once the coroutine unwinds. + isGenerating.set(false) + } + } + } + + /** + * Runs one streaming model turn: creates an agent bubble, streams tokens into it, + * and suspends until completion. Throws on backend error. + * @param llmService the inference service. + * @param prompt the rendered prompt for this turn. + * @param config the generation config. + * @param epoch this run's epoch, for staleness checks against Stop/newer sends. + * @return the final response text (raw, for tool-call extraction). + */ + private suspend fun runModelTurn( + llmService: LlmInferenceService, + prompt: String, + config: LlmInferenceService.LlmConfig, + epoch: Int + ): String { + val deferred = CompletableDeferred() + val agentMessageId = UUID.randomUUID().toString() + val startTime = System.currentTimeMillis() + val responseBuilder = StringBuilder() + + // True once Stop (or a newer message) has superseded this generation. + fun isStale() = generationEpoch.get() != epoch + + withContext(Dispatchers.Main) { + val agentMessage = ChatMessage( + id = agentMessageId, + text = "", + sender = Sender.AGENT, + status = MessageStatus.SENT + ) + _messages.value = _messages.value + agentMessage + syncMessageToSession(agentMessage) + } + + try { + llmService.generateStreaming(prompt, config, object : LlmInferenceService.StreamCallback { + override fun onToken(token: String) { + if (isStale()) return // Stop pressed — ignore late tokens. + responseBuilder.append(token) + // Snapshot on the producer thread; only the immutable String crosses to Main. + val snapshot = responseBuilder.toString() + viewModelScope.launch(Dispatchers.Main) { + if (isStale()) return@launch + val updated = ChatMessage( + id = agentMessageId, + text = snapshot, + sender = Sender.AGENT, + status = MessageStatus.SENT + ) + _messages.value = _messages.value.map { if (it.id == agentMessageId) updated else it } + syncMessageToSession(updated) + } + } + + override fun onComplete(response: LlmInferenceService.LlmResponse) { + if (isStale()) { + // Already cancelled; the awaiting loop was unblocked by job cancel. + deferred.complete(response.text) + return + } + val durationMs = System.currentTimeMillis() - startTime + val toolCalls = ToolCallExtractor.extractToolCalls(response.text) + val respondCall = toolCalls.firstOrNull { it.name == RESPOND_TOOL } + val respondMessage = respondCall?.args?.get("message")?.toString() + + // Per-run flag (set by executeToolCalls), not a session-wide scan. + val lastToolFailed = lastToolFailedThisRun + + val displayText = when { + respondCall != null && lastToolFailed -> + str(R.string.agent_action_failed) + // Render the "respond" message to the user, not a tool badge. + respondCall != null -> + respondMessage?.takeIf { it.isNotBlank() } ?: str(R.string.agent_no_response) + toolCalls.isNotEmpty() -> toolCalls.joinToString("\n") { c -> + "🔧 ${c.name}(${c.args.entries.joinToString(", ") { "${it.key}=${it.value}" }})" + } + else -> response.text.ifBlank { + str(R.string.agent_no_response) } } - }) + viewModelScope.launch(Dispatchers.Main) { + if (isStale()) return@launch + val finalMsg = ChatMessage( + id = agentMessageId, + text = displayText, + sender = Sender.AGENT, + status = MessageStatus.COMPLETED, + durationMs = durationMs + ) + _messages.value = _messages.value.map { if (it.id == agentMessageId) finalMsg else it } + syncMessageToSession(finalMsg) + } + // Return the RAW text to the loop so extraction/stop logic is unaffected. + deferred.complete(response.text) + } - } catch (e: Exception) { - _agentState.value = AgentState.Error("Error: ${e.message}") - val errorMessage = ChatMessage( - id = UUID.randomUUID().toString(), - text = "Error: ${e.message}", - sender = Sender.SYSTEM, - status = MessageStatus.ERROR - ) - _messages.value = _messages.value + errorMessage + override fun onError(error: String) { + if (isStale()) { + deferred.completeExceptionally(CancellationException("stopped")) + return + } + viewModelScope.launch(Dispatchers.Main) { + // Drop the empty/partial bubble; the error surfaces as a SYSTEM message. + _messages.value = _messages.value.filter { it.id != agentMessageId } + } + deferred.completeExceptionally(RuntimeException(error)) + } + }) + } catch (e: Exception) { + // A synchronous throw fires no callback; complete deferred so await() doesn't hang. + android.util.Log.e("ChatViewModel", "generateStreaming threw synchronously", e) + viewModelScope.launch(Dispatchers.Main) { + _messages.value = _messages.value.filter { it.id != agentMessageId } } + if (!deferred.isCompleted) deferred.completeExceptionally(e) + } + + return deferred.await() + } + + /** + * Appends an AGENT message to the chat (terminal state, no streaming dots). + * @param text the message text. + */ + private suspend fun addAgentMessage(text: String) { + val message = ChatMessage( + id = UUID.randomUUID().toString(), + text = text, + sender = Sender.AGENT, + status = MessageStatus.COMPLETED, + durationMs = 0L + ) + withContext(Dispatchers.Main) { + _messages.value = _messages.value + message + syncMessageToSession(message) + } + } + + /** + * Resolves a UI string resource via the plugin's Android context. + * @param resId the string resource id. + * @param args format arguments. + * @return the resolved string, or empty if the context is gone. + */ + private fun str(resId: Int, vararg args: Any?): String = + getContext()?.androidContext?.getString(resId, *args).orEmpty() + + /** + * Appends a SYSTEM message to the chat (on the main thread). + * @param text the message text. + * @param status the message status. + */ + private suspend fun addSystemMessage(text: String, status: MessageStatus) { + val message = ChatMessage( + id = UUID.randomUUID().toString(), + text = text, + sender = Sender.SYSTEM, + status = status + ) + withContext(Dispatchers.Main) { + _messages.value = _messages.value + message + syncMessageToSession(message) } } @@ -678,7 +826,14 @@ class ChatViewModel( * Clear all messages from the conversation. */ fun clearMessages() { + // Clear Chat must also stop any in-flight run, not just wipe the list. + generationEpoch.incrementAndGet() + generationJob?.cancel() + generationJob = null + getLlmService()?.cancelGeneration() + stopStateTimer() _messages.value = emptyList() + _history.value = emptyList() _agentState.value = AgentState.Idle } @@ -690,6 +845,7 @@ class ChatViewModel( _sessions.value = _sessions.value + newSession _currentSessionId.value = newSession.id _messages.value = emptyList() + _history.value = emptyList() } /** @@ -702,6 +858,7 @@ class ChatViewModel( _currentSessionId.value = sessionId // Use immutable snapshot to ensure StateFlow emits on mutations _messages.value = session.messages.toList() + _history.value = emptyList() android.util.Log.d("ChatViewModel", "switchToSession: set _messages to ${session.messages.size} messages") } } @@ -722,10 +879,38 @@ class ChatViewModel( * Stop any ongoing processing. */ fun stopProcessing() { - if (_agentState.value is AgentState.Processing) { - _agentState.value = AgentState.Cancelling - getLlmService()?.cancelGeneration() - _agentState.value = AgentState.Idle + generationEpoch.incrementAndGet() + _agentState.value = AgentState.Cancelling + generationJob?.cancel() + generationJob = null + getLlmService()?.cancelGeneration() + stopStateTimer() + finalizeInProgressMessages() + _agentState.value = AgentState.Idle + } + + /** + * Give any still-streaming agent bubble (status SENT, null `durationMs`) a + * terminal state so its animated "…" dots stop: drop empty bubbles, mark + * partial ones [MessageStatus.COMPLETED]. Called on Stop. + */ + private fun finalizeInProgressMessages() { + val finalized = _messages.value.mapNotNull { msg -> + if (msg.sender == Sender.AGENT && msg.durationMs == null) { + if (msg.text.isBlank()) null + else msg.copy(status = MessageStatus.COMPLETED, durationMs = 0L) + } else { + msg + } + } + _messages.value = finalized + + // Mirror the change into the current session's backing list. + _currentSessionId.value?.let { sessionId -> + _sessions.value.firstOrNull { it.id == sessionId }?.let { session -> + session.messages.clear() + session.messages.addAll(finalized) + } } } @@ -737,9 +922,12 @@ class ChatViewModel( stateUpdateJob?.cancel() stateUpdateJob = viewModelScope.launch { while (isActive) { - delay(100) // Update every 100ms - val elapsed = System.currentTimeMillis() - state.startTime - _agentState.value = state.copy(elapsedMillis = elapsed) + delay(100) + val current = _agentState.value + if (current !is AgentState.Executing) break + _agentState.value = current.copy( + elapsedMillis = System.currentTimeMillis() - current.startTime + ) } } } diff --git a/ai-assistant/src/main/res/values/strings.xml b/ai-assistant/src/main/res/values/strings.xml index b9695d93..cb1bfc2e 100644 --- a/ai-assistant/src/main/res/values/strings.xml +++ b/ai-assistant/src/main/res/values/strings.xml @@ -105,4 +105,33 @@ ⚠️ Experimental AI. Use at your own risk. Current AI backend Send + + + Reached the %1$d-step limit. Ask me to continue if the task isn\'t finished. + Stopped: the model kept requesting the same action. Try rephrasing your request. + I couldn\'t complete that, the last action failed. See the details above. + (No response. Try rephrasing, or pick a larger model in AI Settings — very small models struggle with tool use.) + Generating response… + System Log + + + Initializing engine… + Engine ready + Saved: %s + No model is currently loaded + Loading model, please wait… + ✅ Model loaded: %s + ❌ Error: %s + Loading model… + + + API Key saved on: %s + API Key is saved + API Key saved + API Key cannot be empty + API Key cleared + Current: %s + Loading… + Refresh Models + Model changed to %s diff --git a/ai-assistant/src/test/kotlin/com/itsaky/androidide/plugins/aiassistant/tool/AgentLoopTest.kt b/ai-assistant/src/test/kotlin/com/itsaky/androidide/plugins/aiassistant/tool/AgentLoopTest.kt new file mode 100644 index 00000000..72e4f134 --- /dev/null +++ b/ai-assistant/src/test/kotlin/com/itsaky/androidide/plugins/aiassistant/tool/AgentLoopTest.kt @@ -0,0 +1,341 @@ +package com.itsaky.androidide.plugins.aiassistant.tool + +import com.itsaky.androidide.plugins.aiassistant.models.ToolResult +import com.itsaky.androidide.plugins.services.LlmInferenceService.ChatMessage +import com.itsaky.androidide.plugins.services.LlmInferenceService.ChatMessage.Role +import kotlinx.coroutines.test.runTest +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +/** + * Unit tests for [AgentLoop] — the agentic tool-loop, tested in isolation from + * Android/streaming via plain fakes for generation and tool execution. + */ +class AgentLoopTest { + + /** Records prompts and returns scripted model responses in order. */ + private class ScriptedModel(private val responses: List) { + val prompts = mutableListOf() + var calls = 0 + suspend fun generate(prompt: String): String { + prompts += prompt + val r = responses.getOrElse(calls) { responses.last() } + calls++ + return r + } + } + + private fun toolCall(name: String) = """{"tool":"$name","args":{}}""" + + @Test + fun givenAModelThatCallsNoTools_whenTheLoopRuns_thenItStopsAfterOneTurn() = runTest { + val model = ScriptedModel(listOf("All done — here is your answer.")) + val history = mutableListOf(ChatMessage(Role.USER, "hello")) + var toolsInvoked = 0 + + val result = AgentLoop().run( + history = history, + generate = model::generate, + executeTools = { toolsInvoked++; emptyList() } + ) + + assertTrue(result.completed) + assertEquals(1, result.turns) + assertEquals(1, model.calls) + assertEquals(0, toolsInvoked) + // history now: user + one assistant turn + assertEquals(2, history.size) + assertEquals(Role.ASSISTANT, history[1].role) + } + + @Test + fun givenAModelThatCallsTheTerminalTool_whenTheLoopRuns_thenItFinishesViaOnFinalAnswerWithoutDispatchingIt() = runTest { + // Terminal tool ("respond") finishes via onFinalAnswer and is never dispatched. + val model = ScriptedModel( + listOf("""{"tool":"respond","args":{"message":"All set!"}}""") + ) + val history = mutableListOf(ChatMessage(Role.USER, "hi")) + var toolsInvoked = 0 + var finalTurn = -1 + var finalMessage: String? = null + + val result = AgentLoop(terminalTool = "respond").run( + history = history, + generate = model::generate, + executeTools = { toolsInvoked++; emptyList() }, + events = object : AgentLoop.Events { + override suspend fun onFinalAnswer(turn: Int, message: String) { + finalTurn = turn + finalMessage = message + } + } + ) + + assertTrue(result.completed) + assertEquals(AgentLoop.StopReason.COMPLETED, result.reason) + assertEquals(1, result.turns) + assertEquals(0, toolsInvoked) // terminal tool is NOT dispatched + assertEquals(1, finalTurn) + assertEquals("All set!", finalMessage) + } + + @Test + fun givenAModelThatCallsAToolThenAnswers_whenTheLoopRuns_thenItChainsTheToolAndFinishes() = runTest { + // Turn 1: model calls a tool. Turn 2: sees results, gives final answer. + val model = ScriptedModel( + listOf( + "Let me look. ${toolCall("open_file")}", + "Opened it. Done." + ) + ) + val history = mutableListOf(ChatMessage(Role.USER, "open MainActivity.java")) + val executed = mutableListOf>() + + val result = AgentLoop().run( + history = history, + generate = model::generate, + executeTools = { calls -> + executed += calls + listOf(ToolResult.success("Opened file in editor", "path/to/MainActivity.java")) + } + ) + + assertTrue(result.completed) + assertEquals(2, result.turns) + assertEquals(2, model.calls) + assertEquals(1, executed.size) + assertEquals("open_file", executed[0][0].name) + + // The 2nd prompt must contain the fed-back tool results (the whole point). + assertTrue( + "2nd turn prompt should include tool results", + model.prompts[1].contains("Tool results:") && + model.prompts[1].contains("MainActivity.java") + ) + + // history: user, assistant(turn1), user(tool results), assistant(turn2) + assertEquals(4, history.size) + assertEquals(Role.USER, history[2].role) + assertTrue(history[2].content.startsWith("Tool results:")) + } + + @Test + fun givenAModelThatKeepsCallingDistinctTools_whenTheLoopRuns_thenItStopsAtTheIterationCap() = runTest { + // Distinct calls each turn so stuck-detection doesn't fire before the cap. + val model = ScriptedModel( + listOf( + "step1 ${toolCall("list_files")}", + "step2 ${toolCall("read_file")}", + "step3 ${toolCall("search_project")}" + ) + ) + val history = mutableListOf(ChatMessage(Role.USER, "keep going")) + var maxReachedTurns = -1 + var toolBatches = 0 + + val result = AgentLoop(maxIterations = 3).run( + history = history, + generate = model::generate, + executeTools = { toolBatches++; listOf(ToolResult.success("ok")) }, + events = object : AgentLoop.Events { + override suspend fun onMaxIterationsReached(turns: Int) { maxReachedTurns = turns } + } + ) + + assertFalse(result.completed) + assertEquals(AgentLoop.StopReason.MAX_ITERATIONS, result.reason) + assertEquals(3, result.turns) + assertEquals(3, model.calls) + assertEquals(3, toolBatches) + assertEquals(3, maxReachedTurns) + } + + @Test + fun givenAModelThatRepeatsTheIdenticalToolCall_whenTheLoopRuns_thenItStopsAfterToleratingBoundedRepeats() = runTest { + val model = ScriptedModel(listOf(toolCall("list_files"))) // same call every turn + val history = mutableListOf(ChatMessage(Role.USER, "go")) + var repeatedTurns = -1 + var toolBatches = 0 + + // Default tolerance is 2: one repeat allowed, the second aborts. + val result = AgentLoop(maxIterations = 8).run( + history = history, + generate = model::generate, + executeTools = { toolBatches++; listOf(ToolResult.success("ok")) }, + events = object : AgentLoop.Events { + override suspend fun onRepeatedToolCalls(turns: Int) { repeatedTurns = turns } + } + ) + + assertFalse(result.completed) + assertEquals(AgentLoop.StopReason.REPEATED, result.reason) + assertEquals(3, result.turns) // turn1 + one tolerated repeat, abort on turn3 + assertEquals(3, repeatedTurns) + assertEquals(2, toolBatches) // executed twice, then stopped + } + + @Test + fun givenMaxConsecutiveRepeatsOfOne_whenAModelRepeatsImmediately_thenItStopsOnTheSecondTurn() = runTest { + val model = ScriptedModel(listOf(toolCall("list_files"))) + val history = mutableListOf(ChatMessage(Role.USER, "go")) + var toolBatches = 0 + + val result = AgentLoop(maxIterations = 8, maxConsecutiveRepeats = 1).run( + history = history, + generate = model::generate, + executeTools = { toolBatches++; listOf(ToolResult.success("ok")) } + ) + + assertEquals(AgentLoop.StopReason.REPEATED, result.reason) + assertEquals(2, result.turns) + assertEquals(1, toolBatches) + } + + @Test + fun givenATurnCoEmittingRespondAndARealTool_whenTheLoopRuns_thenTheRealToolStillRuns() = runTest { + // `respond` co-emitted with a real tool must not drop the real tool. + val model = ScriptedModel( + listOf( + """{"tool":"open_file","args":{"file_path":"MainActivity.java"}}""" + + """{"tool":"respond","args":{"message":"Opening it."}}""", + "Done." + ) + ) + val history = mutableListOf(ChatMessage(Role.USER, "open MainActivity")) + val executed = mutableListOf>() + + val result = AgentLoop(terminalTool = "respond").run( + history = history, + generate = model::generate, + executeTools = { calls -> + executed += calls + listOf(ToolResult.success("Opened file in editor", "path/MainActivity.java")) + } + ) + + assertTrue(result.completed) + assertEquals(1, executed.size) + assertEquals(listOf("open_file"), executed[0].map { it.name }) // respond dropped, open_file kept + } + + @Test + fun givenATurnWithOnlyTheTerminalTool_whenTheLoopRuns_thenItFinishesImmediately() = runTest { + val model = ScriptedModel( + listOf("""{"tool":"respond","args":{"message":"Hi!"}}""") + ) + val history = mutableListOf(ChatMessage(Role.USER, "hello")) + var toolsInvoked = 0 + var finalMessage: String? = null + + val result = AgentLoop(terminalTool = "respond").run( + history = history, + generate = model::generate, + executeTools = { toolsInvoked++; emptyList() }, + events = object : AgentLoop.Events { + override suspend fun onFinalAnswer(turn: Int, message: String) { finalMessage = message } + } + ) + + assertTrue(result.completed) + assertEquals(1, result.turns) + assertEquals(0, toolsInvoked) + assertEquals("Hi!", finalMessage) + } + + @Test + fun givenAToolThenAnswerRun_whenTheLoopRuns_thenEventsFireForEachModelTurnAndToolBatch() = runTest { + val model = ScriptedModel(listOf(toolCall("read_file"), "done")) + val history = mutableListOf(ChatMessage(Role.USER, "read it")) + val modelTurns = mutableListOf() + val toolTurns = mutableListOf() + + AgentLoop().run( + history = history, + generate = model::generate, + executeTools = { listOf(ToolResult.success("contents")) }, + events = object : AgentLoop.Events { + override suspend fun onModelTurn(turn: Int, text: String) { modelTurns += turn } + override suspend fun onToolResults(turn: Int, calls: List, results: List) { toolTurns += turn } + } + ) + + assertEquals(listOf(1, 2), modelTurns) + assertEquals(listOf(1), toolTurns) + } + + @Test + fun givenAFailingTool_whenTheLoopRuns_thenTheResultsAreFedBackAsFAILED() = runTest { + val model = ScriptedModel(listOf(toolCall("open_file"), "acknowledged")) + val history = mutableListOf(ChatMessage(Role.USER, "open nope")) + + AgentLoop().run( + history = history, + generate = model::generate, + executeTools = { listOf(ToolResult.failure("File not found", "does not exist")) } + ) + + val fedBack = history.first { it.role == Role.USER && it.content.startsWith("Tool results:") } + assertTrue(fedBack.content.contains("FAILED")) + assertTrue(fedBack.content.contains("File not found")) + } + + @Test + fun givenLongToolOutput_whenTheLoopRuns_thenItIsTruncatedBeforeFeedingBack() = runTest { + val big = "x".repeat(10_000) + val model = ScriptedModel(listOf(toolCall("read_file"), "ok")) + val history = mutableListOf(ChatMessage(Role.USER, "read big")) + + AgentLoop(toolOutputCharLimit = 500).run( + history = history, + generate = model::generate, + executeTools = { listOf(ToolResult.success("read", big)) } + ) + + val fedBack = history.first { it.content.startsWith("Tool results:") } + assertTrue(fedBack.content.contains("truncated")) + assertFalse("full 10k output must not be fed back", fedBack.content.contains(big)) + } + + @Test + fun givenASuccessfulToolResult_whenFormatToolResultsIsCalled_thenItBiasesTheModelToStop() { + val loop = AgentLoop() + val fedBack = loop.formatToolResults( + listOf(ToolCall("open_file", emptyMap())), + listOf(ToolResult.success("Opened file in editor", ".gitignore")) + ) + // After success, finishing is the default and another tool call is discouraged. + assertTrue(fedBack.contains("you are DONE")) + assertTrue(fedBack.contains("respond")) + assertTrue(fedBack.contains("Do NOT call another tool")) + } + + @Test + fun givenAFailedToolResult_whenFormatToolResultsIsCalled_thenItKeepsTheOpenEndedNextToolCue() { + val loop = AgentLoop() + val fedBack = loop.formatToolResults( + listOf(ToolCall("open_file", emptyMap())), + listOf(ToolResult.failure("File not found", "does not exist")) + ) + assertTrue(fedBack.contains("FAILED")) + assertTrue(fedBack.contains("call the next tool")) + } + + @Test + fun givenATranscript_whenRenderTranscriptIsCalled_thenItLabelsAssistantTurnsAndAddsNoTrailingCue() { + val loop = AgentLoop() + val transcript = loop.renderTranscript( + listOf( + ChatMessage(Role.USER, "hi"), + ChatMessage(Role.ASSISTANT, "hello"), + ChatMessage(Role.USER, "Tool results:\n[list_files] ok") + ) + ) + assertTrue(transcript.contains("hi")) + assertTrue(transcript.contains("Assistant: hello")) + assertTrue(transcript.contains("Tool results:")) + // Must not append a trailing "Assistant:" cue (the backend adds its own). + assertFalse("must not append a trailing Assistant cue", transcript.trimEnd().endsWith("Assistant:")) + } +} diff --git a/ai-assistant/src/test/kotlin/com/itsaky/androidide/plugins/aiassistant/tool/ExecutorTest.kt b/ai-assistant/src/test/kotlin/com/itsaky/androidide/plugins/aiassistant/tool/ExecutorTest.kt new file mode 100644 index 00000000..163c6384 --- /dev/null +++ b/ai-assistant/src/test/kotlin/com/itsaky/androidide/plugins/aiassistant/tool/ExecutorTest.kt @@ -0,0 +1,115 @@ +package com.itsaky.androidide.plugins.aiassistant.tool + +import com.itsaky.androidide.plugins.aiassistant.models.ToolResult +import com.itsaky.androidide.plugins.aiassistant.tool.handlers.PathGuard +import kotlinx.coroutines.runBlocking +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test +import java.io.File +import java.nio.file.Files + +/** + * Unit tests for the [Executor] path-containment pre-guard, in particular the + * [ToolHandler.resolvesPathsInternally] opt-out that lets read-only handlers + * rescue odd paths (e.g. a model-supplied "/.gitignore") instead of the Executor + * rejecting them outright, while write tools stay guarded. + */ +class ExecutorTest { + + private lateinit var projectRoot: File + + /** A handler that records whether it was dispatched and always succeeds. */ + private class FakeHandler( + override val toolName: String, + override val resolvesPathsInternally: Boolean, + ) : ToolHandler { + override val description = "fake" + override val requiresApproval = false + override val pathArgs = listOf("file_path") + var dispatched = false + private set + + override suspend fun execute(args: Map): ToolResult { + dispatched = true + return ToolResult.success("ran") + } + } + + @Before + fun setup() { + projectRoot = Files.createTempDirectory("executor-project").toFile().canonicalFile + PathGuard.setProjectRootForTesting(projectRoot.absolutePath) + } + + @After + fun tearDown() { + PathGuard.setProjectRootForTesting(null) + } + + private fun executorFor(handler: ToolHandler): Executor = + Executor(ToolRouter(listOf(handler)), ToolApprovalManager()) + + @Test + fun givenAnInternallyResolvingHandler_whenExecutingAnEscapingPath_thenTheEscapePreGuardIsBypassed() = runBlocking { + val handler = FakeHandler("fake_internal", resolvesPathsInternally = true) + val executor = executorFor(handler) + + // "/escape.txt" resolves outside the project root; the guard would reject + // it, but an internally-resolving handler must still be dispatched. + val results = executor.execute(listOf(ToolCall("fake_internal", mapOf("file_path" to "/escape.txt")))) + + assertTrue("handler should have been dispatched", handler.dispatched) + assertTrue("result should be the handler's success", results.single().success) + } + + @Test + fun givenADefaultHandler_whenExecutingAPathThatEscapesTheProjectRoot_thenItIsRejected() = runBlocking { + val handler = FakeHandler("fake_guarded", resolvesPathsInternally = false) + val executor = executorFor(handler) + + val results = executor.execute(listOf(ToolCall("fake_guarded", mapOf("file_path" to "/escape.txt")))) + + assertFalse("handler must NOT run for an escaping write path", handler.dispatched) + assertFalse(results.single().success) + assertTrue(results.single().message.contains("outside the project directory")) + } + + @Test + fun givenOpenFileWithAPathAlias_whenExecuting_thenPathIsRemappedToFilePathAndItRuns() = runBlocking { + // open_file requires file_path (like read_file); a model emitting + // {"path":"..."} must be remapped, not rejected for a missing file_path. + val handler = object : ToolHandler { + override val toolName = "open_file" + override val description = "fake open" + override val requiresApproval = false + override val pathArgs = listOf("file_path") + override val resolvesPathsInternally = true + var seenArgs: Map? = null + override suspend fun execute(args: Map): ToolResult { + seenArgs = args + return ToolResult.success("opened") + } + } + val executor = executorFor(handler) + + val results = executor.execute(listOf(ToolCall("open_file", mapOf("path" to "MainActivity.java")))) + + assertTrue("open_file with a path alias should run", results.single().success) + assertEquals("MainActivity.java", handler.seenArgs?.get("file_path")) + } + + @Test + fun givenADefaultHandler_whenExecutingAnInProjectPath_thenItRuns() = runBlocking { + val handler = FakeHandler("fake_guarded", resolvesPathsInternally = false) + val executor = executorFor(handler) + + val results = executor.execute(listOf(ToolCall("fake_guarded", mapOf("file_path" to "notes.txt")))) + + assertTrue("in-project path should be allowed through", handler.dispatched) + assertTrue(results.single().success) + } +} diff --git a/ai-assistant/src/test/kotlin/com/itsaky/androidide/plugins/aiassistant/tool/ToolCallExtractorTest.kt b/ai-assistant/src/test/kotlin/com/itsaky/androidide/plugins/aiassistant/tool/ToolCallExtractorTest.kt new file mode 100644 index 00000000..a7671b1c --- /dev/null +++ b/ai-assistant/src/test/kotlin/com/itsaky/androidide/plugins/aiassistant/tool/ToolCallExtractorTest.kt @@ -0,0 +1,66 @@ +package com.itsaky.androidide.plugins.aiassistant.tool + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test + +/** + * Unit tests for [ToolCallExtractor]. Focus: only explicit tool calls are honored, + * and prose never fires a tool (the "build Android apps" -> run_app regression). + */ +class ToolCallExtractorTest { + + @Test + fun givenAnExplicitToolCallTag_whenExtracting_thenTheCallIsExtractedWithArgs() { + val calls = ToolCallExtractor.extractToolCalls( + """Sure. {"tool":"open_file","args":{"file_path":"app/Main.java"}}""" + ) + assertEquals(1, calls.size) + assertEquals("open_file", calls[0].name) + assertEquals("app/Main.java", calls[0].args["file_path"]) + } + + @Test + fun givenABareJsonToolCall_whenExtracting_thenItIsExtracted() { + val calls = ToolCallExtractor.extractToolCalls( + """{"tool":"list_files","args":{"directory":"src"}}""" + ) + assertEquals(1, calls.size) + assertEquals("list_files", calls[0].name) + assertEquals("src", calls[0].args["directory"]) + } + + @Test + fun givenABareJsonToolCallWhoseValueContainsBraces_whenExtracting_thenItIsExtractedIntact() { + // The brace counter must ignore braces inside string values. + val calls = ToolCallExtractor.extractToolCalls( + """{"tool":"create_file","args":{"file_path":"A.kt","content":"fun f() { if (x) { y() } }"}}""" + ) + assertEquals(1, calls.size) + assertEquals("create_file", calls[0].name) + assertEquals("A.kt", calls[0].args["file_path"]) + assertEquals("fun f() { if (x) { y() } }", calls[0].args["content"]) + } + + @Test + fun givenAChattyReplyMentioningBuildingApps_whenExtracting_thenNoToolIsFired() { + val calls = ToolCallExtractor.extractToolCalls( + "Hi! I can help you build Android apps. What would you like to run or create next?" + ) + assertTrue("prose must not produce tool calls, got $calls", calls.isEmpty()) + } + + @Test + fun givenAPlainGreeting_whenExtracting_thenNoToolCallsAreProduced() { + assertTrue(ToolCallExtractor.extractToolCalls("Hello, how can I help?").isEmpty()) + } + + @Test + fun givenNarratedIntentWithoutATag_whenExtracting_thenNoToolCallsAreProduced() { + // The model describing what it would do must NOT be treated as a tool call. + val calls = ToolCallExtractor.extractToolCalls( + "Let me list the files in src and then read MainActivity.kt for you." + ) + assertTrue("narration must not produce tool calls, got $calls", calls.isEmpty()) + } +} diff --git a/ai-assistant/src/test/kotlin/com/itsaky/androidide/plugins/aiassistant/tool/handlers/ListFilesHandlerTest.kt b/ai-assistant/src/test/kotlin/com/itsaky/androidide/plugins/aiassistant/tool/handlers/ListFilesHandlerTest.kt new file mode 100644 index 00000000..c4fca097 --- /dev/null +++ b/ai-assistant/src/test/kotlin/com/itsaky/androidide/plugins/aiassistant/tool/handlers/ListFilesHandlerTest.kt @@ -0,0 +1,127 @@ +package com.itsaky.androidide.plugins.aiassistant.tool.handlers + +import com.itsaky.androidide.plugins.PluginContext +import io.mockk.mockk +import kotlinx.coroutines.runBlocking +import org.junit.After +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test +import java.io.File +import java.nio.file.Files + +/** + * Unit tests for [ListFilesHandler] after it was refactored to resolve and + * containment-check paths through the shared [PathGuard]. + */ +class ListFilesHandlerTest { + + private lateinit var projectRoot: File + private lateinit var handler: ListFilesHandler + + @Before + fun setup() { + projectRoot = Files.createTempDirectory("listfiles-project").toFile().canonicalFile + PathGuard.setProjectRootForTesting(projectRoot.absolutePath) + handler = ListFilesHandler(mockk(relaxed = true)) + } + + @After + fun tearDown() { + PathGuard.setProjectRootForTesting(null) + PathGuard.setProjectRootProvider(null) + projectRoot.deleteRecursively() + } + + @Test + fun givenNoDirectoryArg_whenListing_thenItListsTheProjectRoot() = runBlocking { + File(projectRoot, "README.md").writeText("hi") + File(projectRoot, "app").mkdirs() + + val result = handler.execute(emptyMap()) + + assertTrue("Expected success, got: ${result.message}", result.success) + val data = result.data.orEmpty() + assertTrue(data.contains("README.md")) + assertTrue(data.contains("app")) + } + + @Test + fun givenABlankDirectoryArg_whenListing_thenItListsTheProjectRoot() = runBlocking { + File(projectRoot, "build.gradle.kts").writeText("x") + + val result = handler.execute(mapOf("directory" to " ")) + + assertTrue(result.success) + assertTrue(result.data.orEmpty().contains("build.gradle.kts")) + } + + @Test + fun givenASubdirectoryArg_whenListing_thenItListsThatSubdirectory() = runBlocking { + File(projectRoot, "src/main").mkdirs() + File(projectRoot, "src/main/Main.kt").writeText("fun main() {}") + + val result = handler.execute(mapOf("directory" to "src/main")) + + assertTrue(result.success) + assertTrue(result.data.orEmpty().contains("Main.kt")) + } + + @Test + fun givenASlashPrefixedRelativeDirectory_whenListing_thenItResolvesAsRelative() = runBlocking { + // A slash-prefixed relative dir must fall back to relative-to-root. + File(projectRoot, "src/main").mkdirs() + File(projectRoot, "src/main/Main.kt").writeText("fun main() {}") + + val result = handler.execute(mapOf("directory" to "/src/main")) + + assertTrue("Expected success, got: ${result.message}", result.success) + assertTrue(result.data.orEmpty().contains("Main.kt")) + } + + @Test + fun givenASlashPrefixedEscapingDirectory_whenListing_thenItIsStillRejected() = runBlocking { + // The fallback must not become a containment bypass. + val result = handler.execute(mapOf("directory" to "/../etc")) + + assertFalse(result.success) + assertTrue(result.message.contains("within project directory")) + } + + @Test + fun givenADirectoryEscapingTheProjectRoot_whenListing_thenItIsRejected() = runBlocking { + val result = handler.execute(mapOf("directory" to "../")) + + assertFalse(result.success) + assertTrue(result.message.contains("within project directory")) + } + + @Test + fun givenANonexistentDirectory_whenListing_thenItFails() = runBlocking { + val result = handler.execute(mapOf("directory" to "nope")) + + assertFalse(result.success) + assertTrue(result.message.contains("does not exist")) + } + + @Test + fun givenAFilePath_whenListing_thenItIsRejectedAsNotADirectory() = runBlocking { + File(projectRoot, "file.txt").writeText("x") + + val result = handler.execute(mapOf("directory" to "file.txt")) + + assertFalse(result.success) + assertTrue(result.message.contains("not a directory")) + } + + @Test + fun givenAnEmptyDirectory_whenListing_thenItReportsSuccessWithNoEntries() = runBlocking { + File(projectRoot, "empty").mkdirs() + + val result = handler.execute(mapOf("directory" to "empty")) + + assertTrue(result.success) + assertTrue(result.data.orEmpty().contains("no files or directories")) + } +} diff --git a/ai-assistant/src/test/kotlin/com/itsaky/androidide/plugins/aiassistant/tool/handlers/OpenFileHandlerTest.kt b/ai-assistant/src/test/kotlin/com/itsaky/androidide/plugins/aiassistant/tool/handlers/OpenFileHandlerTest.kt new file mode 100644 index 00000000..e600e335 --- /dev/null +++ b/ai-assistant/src/test/kotlin/com/itsaky/androidide/plugins/aiassistant/tool/handlers/OpenFileHandlerTest.kt @@ -0,0 +1,214 @@ +package com.itsaky.androidide.plugins.aiassistant.tool.handlers + +import com.itsaky.androidide.plugins.PluginContext +import com.itsaky.androidide.plugins.ServiceRegistry +import com.itsaky.androidide.plugins.services.IdeEditorService +import io.mockk.every +import io.mockk.mockk +import io.mockk.verify +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.runBlocking +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test +import java.io.File +import java.nio.file.Files + +/** + * Unit tests for [OpenFileHandler] — the tool that opens a project file in the + * IDE editor. Covers every branch, including the path-containment guard that + * regressed on `open_file .gitignore`. + */ +class OpenFileHandlerTest { + + private lateinit var projectRoot: File + private lateinit var context: PluginContext + private lateinit var services: ServiceRegistry + private lateinit var editorService: IdeEditorService + private lateinit var handler: OpenFileHandler + + @Before + fun setup() { + projectRoot = Files.createTempDirectory("openfile-project").toFile().canonicalFile + PathGuard.setProjectRootForTesting(projectRoot.absolutePath) + + editorService = mockk(relaxed = true) + services = mockk() + context = mockk() + every { context.services } returns services + every { services.get(IdeEditorService::class.java) } returns editorService + + // Unconfined: no Android main looper in a JVM test. + handler = OpenFileHandler(context, Dispatchers.Unconfined) + } + + @After + fun tearDown() { + PathGuard.setProjectRootForTesting(null) + PathGuard.setProjectRootProvider(null) + projectRoot.deleteRecursively() + } + + private fun createFile(relative: String): File = + File(projectRoot, relative).apply { + parentFile?.mkdirs() + writeText("content") + } + + @Test + fun givenAnExistingProjectFile_whenOpened_thenItSucceeds() = runBlocking { + createFile(".gitignore") + every { editorService.openFile(any()) } returns true + + val result = handler.execute(mapOf("file_path" to ".gitignore")) + + assertTrue("Expected success, got: ${result.message}", result.success) + assertEquals(".gitignore", result.data) + verify { editorService.openFile(File(projectRoot, ".gitignore")) } + } + + @Test + fun givenALeadingSlashPath_whenOpened_thenItFallsBackToBasenameSearchAndOpens() = runBlocking { + // The model sometimes prepends a slash ("/.gitignore"), which resolves + // outside the project root; the basename fallback must still find it. + createFile(".gitignore") + every { editorService.openFile(any()) } returns true + + val result = handler.execute(mapOf("file_path" to "/.gitignore")) + + assertTrue("Expected success, got: ${result.message}", result.success) + verify { editorService.openFile(File(projectRoot, ".gitignore")) } + } + + @Test + fun givenALeadingSlashDirectoryQualifiedPath_whenOpened_thenItResolvesAsRelativeAndOpens() = runBlocking { + // With two .gitignore files, a bare basename is ambiguous; the model + // disambiguates with "app/.gitignore" but sends it slash-prefixed. It + // must resolve to the exact file, not collapse back to a basename search. + createFile(".gitignore") + createFile("app/.gitignore") + every { editorService.openFile(any()) } returns true + + val result = handler.execute(mapOf("file_path" to "/app/.gitignore")) + + assertTrue("Expected success, got: ${result.message}", result.success) + verify { editorService.openFile(File(projectRoot, "app/.gitignore")) } + } + + @Test + fun givenAMissingFilePath_whenOpened_thenItFails() = runBlocking { + val result = handler.execute(emptyMap()) + + assertFalse(result.success) + assertTrue(result.message.contains("file_path is required")) + verify(exactly = 0) { editorService.openFile(any()) } + } + + @Test + fun givenABlankFilePath_whenOpened_thenItFails() = runBlocking { + val result = handler.execute(mapOf("file_path" to " ")) + + assertFalse(result.success) + verify(exactly = 0) { editorService.openFile(any()) } + } + + @Test + fun givenAPathEscapingTheProjectRoot_whenOpened_thenItIsRejected() = runBlocking { + val result = handler.execute(mapOf("file_path" to "../outside.txt")) + + assertFalse(result.success) + assertTrue(result.message.contains("within project directory")) + verify(exactly = 0) { editorService.openFile(any()) } + } + + @Test + fun givenANonexistentFile_whenOpened_thenItFailsWithNotFound() = runBlocking { + val result = handler.execute(mapOf("file_path" to "does/not/exist.kt")) + + assertFalse(result.success) + assertEquals("File not found", result.message) + verify(exactly = 0) { editorService.openFile(any()) } + } + + @Test + fun givenALeadingSlashNonexistentPath_whenOpened_thenItReportsNotFoundRatherThanAnEscape() = runBlocking { + // "/does/not/exist.kt" has an in-root reading ("does/not/exist.kt") that + // simply doesn't exist — so the user sees "File not found", not the + // misleading "must be within project directory" (which is reserved for a + // real containment escape like "../outside.txt"). + val result = handler.execute(mapOf("file_path" to "/does/not/exist.kt")) + + assertFalse(result.success) + assertEquals("File not found", result.message) + verify(exactly = 0) { editorService.openFile(any()) } + } + + @Test + fun givenADirectoryPath_whenOpened_thenItIsRejectedAsNotAFile() = runBlocking { + File(projectRoot, "somedir").mkdirs() + + val result = handler.execute(mapOf("file_path" to "somedir")) + + assertFalse(result.success) + assertEquals("Not a file", result.message) + verify(exactly = 0) { editorService.openFile(any()) } + } + + @Test + fun givenNoEditorService_whenOpened_thenItFailsGracefully() = runBlocking { + createFile("Main.kt") + every { services.get(IdeEditorService::class.java) } returns null + + val result = handler.execute(mapOf("file_path" to "Main.kt")) + + assertFalse(result.success) + assertEquals("Editor service not available", result.message) + } + + @Test + fun givenABareFilename_whenOpened_thenItResolvesToItsRealNestedPath() = runBlocking { + createFile("app/src/main/java/com/example/MainActivity.java") + every { editorService.openFile(any()) } returns true + + val result = handler.execute(mapOf("file_path" to "MainActivity.java")) + + assertTrue("Expected success, got: ${result.message}", result.success) + verify { + editorService.openFile(File(projectRoot, "app/src/main/java/com/example/MainActivity.java")) + } + } + + @Test + fun givenAnAmbiguousBareFilename_whenOpened_thenItReturnsTheCandidatesInsteadOfOpening() = runBlocking { + createFile("app/src/main/java/A/Strings.kt") + createFile("app/src/main/java/B/Strings.kt") + + val result = handler.execute(mapOf("file_path" to "Strings.kt")) + + assertFalse(result.success) + assertTrue(result.message.contains("Multiple files")) + verify(exactly = 0) { editorService.openFile(any()) } + } + + @Test + fun givenABareFilenameWithNoMatch_whenOpened_thenItReportsNotFound() = runBlocking { + val result = handler.execute(mapOf("file_path" to "Nope.java")) + + assertFalse(result.success) + assertEquals("File not found", result.message) + } + + @Test + fun givenTheEditorReportingFailure_whenOpened_thenItSurfacesAFailureResult() = runBlocking { + createFile("Main.kt") + every { editorService.openFile(any()) } returns false + + val result = handler.execute(mapOf("file_path" to "Main.kt")) + + assertFalse(result.success) + assertEquals("Failed to open file", result.message) + } +} diff --git a/ai-assistant/src/test/kotlin/com/itsaky/androidide/plugins/aiassistant/tool/handlers/PathGuardTest.kt b/ai-assistant/src/test/kotlin/com/itsaky/androidide/plugins/aiassistant/tool/handlers/PathGuardTest.kt new file mode 100644 index 00000000..bbed8ca6 --- /dev/null +++ b/ai-assistant/src/test/kotlin/com/itsaky/androidide/plugins/aiassistant/tool/handlers/PathGuardTest.kt @@ -0,0 +1,265 @@ +package com.itsaky.androidide.plugins.aiassistant.tool.handlers + +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test +import java.io.File +import java.nio.file.Files + +/** + * Unit tests for [PathGuard] — the shared containment guard for filesystem tools; + * covers the regression where anchoring to `user.dir` ("/" on Android) rejected + * every relative path (`open_file .gitignore` → "outside the project directory"). + */ +class PathGuardTest { + + private lateinit var projectRoot: File + + @Before + fun setup() { + projectRoot = Files.createTempDirectory("pathguard-project").toFile().canonicalFile + PathGuard.setProjectRootForTesting(projectRoot.absolutePath) + } + + @After + fun tearDown() { + // Reset shared singleton state so tests don't leak into each other. + PathGuard.setProjectRootForTesting(null) + PathGuard.setProjectRootProvider(null) + projectRoot.deleteRecursively() + } + + // --- The reported bug ----------------------------------------------------- + + @Test + fun givenARelativeDotfile_whenResolveWithinIsCalled_thenItResolvesInsideTheProjectRoot() { + val resolved = PathGuard.resolveWithin(".gitignore") + + assertNotNull("'.gitignore' must resolve inside the project root", resolved) + assertEquals(File(projectRoot, ".gitignore").canonicalPath, resolved!!.canonicalPath) + } + + @Test + fun givenTheFilesystemRootAsProjectRoot_whenAnyPathIsResolved_thenItIsRejected() { + // A root of "/" means no project is open; the guard must reject everything. + PathGuard.setProjectRootForTesting("/") + + assertNull("a relative path under '/' must be rejected", PathGuard.resolveWithin(".gitignore")) + assertNull("an absolute path under '/' must be rejected", PathGuard.resolveWithin("/etc/passwd")) + } + + @Test + fun givenANonExistentProjectRoot_whenAPathIsResolved_thenItIsRejected() { + PathGuard.setProjectRootForTesting("/no/such/project/dir/anywhere") + + assertNull(PathGuard.resolveWithin("build.gradle.kts")) + } + + // --- Normal containment --------------------------------------------------- + + @Test + fun givenANestedRelativePath_whenResolveWithinIsCalled_thenItResolvesInsideTheProjectRoot() { + val resolved = PathGuard.resolveWithin("app/src/main/AndroidManifest.xml") + + assertNotNull(resolved) + assertEquals( + File(projectRoot, "app/src/main/AndroidManifest.xml").canonicalPath, + resolved!!.canonicalPath + ) + } + + @Test + fun givenTheProjectRootPath_whenResolveWithinIsCalled_thenItResolves() { + val resolved = PathGuard.resolveWithin(".") + + assertNotNull(resolved) + assertEquals(projectRoot.canonicalPath, resolved!!.canonicalPath) + } + + @Test + fun givenAnAbsolutePathInsideTheRoot_whenResolveWithinIsCalled_thenItResolves() { + val inside = File(projectRoot, "build.gradle.kts").absolutePath + + val resolved = PathGuard.resolveWithin(inside) + + assertNotNull(resolved) + assertEquals(File(inside).canonicalPath, resolved!!.canonicalPath) + } + + // --- Escape attempts are rejected ---------------------------------------- + + @Test + fun givenARelativeTraversalEscapingTheRoot_whenResolveWithinIsCalled_thenItIsRejected() { + assertNull(PathGuard.resolveWithin("../secrets.txt")) + } + + @Test + fun givenAnAbsolutePathOutsideTheRoot_whenResolveWithinIsCalled_thenItIsRejected() { + assertNull(PathGuard.resolveWithin("/etc/passwd")) + } + + @Test + fun givenASiblingDirectorySharingANamePrefix_whenResolveWithinIsCalled_thenItIsRejected() { + // e.g. root "/tmp/proj" must NOT accept "/tmp/proj-evil". + assertNull(PathGuard.resolveWithin(projectRoot.absolutePath + "-evil/file.txt")) + } + + // --- Root resolution precedence ------------------------------------------ + + @Test + fun givenNoTestOverride_whenTheProjectRootIsQueried_thenTheProviderSuppliesIt() { + PathGuard.setProjectRootForTesting(null) + val providerRoot = Files.createTempDirectory("pathguard-provider").toFile().canonicalFile + try { + PathGuard.setProjectRootProvider { providerRoot.absolutePath } + + assertEquals(providerRoot.canonicalPath, File(PathGuard.projectRoot()).canonicalPath) + assertNotNull(PathGuard.resolveWithin("settings.gradle")) + } finally { + providerRoot.deleteRecursively() + } + } + + // --- findByName (basename resolution) ------------------------------------ + + @Test + fun givenANestedFile_whenFindByNameIsCalledWithItsBasename_thenItIsLocated() { + File(projectRoot, "app/src/main/java/com/example").mkdirs() + File(projectRoot, "app/src/main/java/com/example/MainActivity.java").writeText("x") + + val found = PathGuard.findByName("MainActivity.java") + + assertEquals(1, found.size) + assertEquals( + File(projectRoot, "app/src/main/java/com/example/MainActivity.java").canonicalPath, + found[0].canonicalPath + ) + } + + @Test + fun givenFilesDifferingOnlyInCase_whenFindByNameIsCalled_thenItMatchesAllCaseInsensitively() { + File(projectRoot, "a").mkdirs(); File(projectRoot, "a/Notes.txt").writeText("x") + File(projectRoot, "b").mkdirs(); File(projectRoot, "b/notes.txt").writeText("x") + + assertEquals(2, PathGuard.findByName("notes.txt").size) + } + + @Test + fun givenFilesUnderBuildAndDotDirectories_whenFindByNameIsCalled_thenTheyAreSkipped() { + File(projectRoot, "build/generated").mkdirs() + File(projectRoot, "build/generated/R.java").writeText("x") + File(projectRoot, ".git").mkdirs() + File(projectRoot, ".git/R.java").writeText("x") + + assertTrue("must not match files under build/ or .git/", PathGuard.findByName("R.java").isEmpty()) + } + + @Test + fun givenNoMatchingFile_whenFindByNameIsCalled_thenItReturnsEmpty() { + assertTrue(PathGuard.findByName("DoesNotExist.kt").isEmpty()) + } + + @Test + fun givenAFileReachableOnlyThroughASymlinkedDir_whenFindByNameIsCalled_thenItIsNotMatched() { + // A symlinked project subdir must not let the walk escape the root. + val outside = Files.createTempDirectory("pathguard-outside").toFile().canonicalFile + try { + File(outside, "Secret.kt").writeText("x") + val link = File(projectRoot, "linked").toPath() + try { + Files.createSymbolicLink(link, outside.toPath()) + } catch (e: Exception) { + // Filesystem/OS without symlink support — nothing to assert. + return + } + assertTrue( + "a match reachable only via a symlinked dir must be dropped", + PathGuard.findByName("Secret.kt").isEmpty() + ) + } finally { + outside.deleteRecursively() + } + } + + // --- resolve() — the shared handler resolution policy -------------------- + + @Test + fun givenAnExistingRelativePath_whenResolveIsCalled_thenItReturnsResolved() { + File(projectRoot, "app").mkdirs() + val target = File(projectRoot, "app/build.gradle.kts").apply { writeText("x") } + + val resolution = PathGuard.resolve("app/build.gradle.kts") + + assertTrue(resolution is PathGuard.Resolution.Resolved) + assertEquals(target.canonicalPath, (resolution as PathGuard.Resolution.Resolved).file.canonicalPath) + } + + @Test + fun givenASlashPrefixedPath_whenResolveIsCalled_thenItRetriesAsRelativeBeforeBasenameSearch() { + // The slash-stripped relative retry must resolve before an ambiguous basename search. + File(projectRoot, "app").mkdirs(); File(projectRoot, "app/.gitignore").writeText("x") + File(projectRoot, ".gitignore").writeText("x") + + val resolution = PathGuard.resolve("/app/.gitignore") + + assertTrue(resolution is PathGuard.Resolution.Resolved) + assertEquals( + File(projectRoot, "app/.gitignore").canonicalPath, + (resolution as PathGuard.Resolution.Resolved).file.canonicalPath + ) + } + + @Test + fun givenABareName_whenResolveIsCalled_thenItReturnsResolvedViaBasenameSearch() { + File(projectRoot, "app/src/main").mkdirs() + File(projectRoot, "app/src/main/MainActivity.java").writeText("x") + + val resolution = PathGuard.resolve("MainActivity.java") + + assertTrue(resolution is PathGuard.Resolution.Resolved) + } + + @Test + fun givenABasenameMatchingSeveralFiles_whenResolveIsCalled_thenItReturnsAmbiguous() { + File(projectRoot, "a").mkdirs(); File(projectRoot, "a/Strings.kt").writeText("x") + File(projectRoot, "b").mkdirs(); File(projectRoot, "b/Strings.kt").writeText("x") + + val resolution = PathGuard.resolve("Strings.kt") + + assertTrue(resolution is PathGuard.Resolution.Ambiguous) + assertEquals("Strings.kt", (resolution as PathGuard.Resolution.Ambiguous).baseName) + assertEquals(2, resolution.matches.size) + } + + @Test + fun givenAPathWithNoInRootInterpretation_whenResolveIsCalled_thenItReturnsEscaped() { + assertEquals(PathGuard.Resolution.Escaped, PathGuard.resolve("../outside.txt")) + } + + @Test + fun givenASlashPrefixedInRootPathThatDoesNotExist_whenResolveIsCalled_thenItReturnsNotFound() { + // Absolute miss but in-root as relative → NotFound, not Escaped. + assertEquals(PathGuard.Resolution.NotFound, PathGuard.resolve("/does/exist.kt")) + } + + @Test + fun givenAnInRootPathThatDoesNotExist_whenResolveIsCalled_thenItReturnsNotFound() { + assertEquals(PathGuard.Resolution.NotFound, PathGuard.resolve("does/not/exist.kt")) + } + + @Test + fun givenABlankProviderResult_whenTheProjectRootIsQueried_thenItFallsThroughToTheNextSource() { + PathGuard.setProjectRootForTesting(null) + PathGuard.setProjectRootProvider { " " } + System.setProperty("project.dir", projectRoot.absolutePath) + try { + assertEquals(projectRoot.canonicalPath, File(PathGuard.projectRoot()).canonicalPath) + } finally { + System.clearProperty("project.dir") + } + } +} diff --git a/ai-core/libs/llama-api.jar b/ai-core/libs/llama-api.jar index 3274247d..6d160ed2 100644 Binary files a/ai-core/libs/llama-api.jar and b/ai-core/libs/llama-api.jar differ diff --git a/ai-core/libs/v8/llama-v8-release.aar b/ai-core/libs/v8/llama-v8-release.aar index 9b2984f4..d7730b01 100644 Binary files a/ai-core/libs/v8/llama-v8-release.aar and b/ai-core/libs/v8/llama-v8-release.aar differ diff --git a/ai-core/llama-impl/src/main/cpp/llama-android.cpp b/ai-core/llama-impl/src/main/cpp/llama-android.cpp index 91cff6e9..e0771fe4 100644 --- a/ai-core/llama-impl/src/main/cpp/llama-android.cpp +++ b/ai-core/llama-impl/src/main/cpp/llama-android.cpp @@ -17,6 +17,16 @@ #define LOGi(...) __android_log_print(ANDROID_LOG_INFO, TAG, __VA_ARGS__) #define LOGe(...) __android_log_print(ANDROID_LOG_ERROR, TAG, __VA_ARGS__) +// Verbose, per-token tracing. It fires once per prompt token and once per +// generated token — hundreds of log/JNI calls per reply on the hot path — so it +// is compiled out of release builds (NDEBUG). The __VA_ARGS__ are not evaluated +// in release, so any string-building in the arguments is skipped too. +#ifdef NDEBUG +#define LOGv(...) ((void) 0) +#else +#define LOGv(...) __android_log_print(ANDROID_LOG_INFO, TAG, __VA_ARGS__) +#endif + jclass la_int_var; jmethodID la_int_var_value; jmethodID la_int_var_inc; @@ -554,6 +564,51 @@ Java_android_llama_cpp_LLamaAndroid_new_1sampler(JNIEnv *, jobject) { return reinterpret_cast(smpl); } +/** + * Build a sampler chain constrained by a GBNF grammar, so the model can only + * emit tokens the grammar allows — used for reliable text-based tool calls on + * weak local models. + * + * @param model_pointer native llama_model handle. + * @param grammar GBNF grammar text, entered at its "root" rule. + * @return the native sampler handle, or 0 if the model is null or the grammar + * fails to parse (the caller falls back to the plain sampler). + */ +extern "C" +JNIEXPORT jlong JNICALL +Java_android_llama_cpp_LLamaAndroid_new_1grammar_1sampler( + JNIEnv *env, jobject, jlong model_pointer, jstring grammar) { + const auto model = reinterpret_cast(model_pointer); + if (model == nullptr) return 0; + if (grammar == nullptr) return 0; // no grammar string — caller falls back + + const llama_vocab *vocab = llama_model_get_vocab(model); + if (vocab == nullptr) return 0; // model has no vocab — can't build a grammar sampler + + // NULL under memory pressure (pending OOM); clear the exception and fall back. + const char *grammar_cstr = env->GetStringUTFChars(grammar, nullptr); + if (grammar_cstr == nullptr) { + if (env->ExceptionCheck()) env->ExceptionClear(); + return 0; + } + llama_sampler *grmr = llama_sampler_init_grammar(vocab, grammar_cstr, "root"); + env->ReleaseStringUTFChars(grammar, grammar_cstr); + if (grmr == nullptr) return 0; // invalid grammar — caller falls back + + auto sparams = llama_sampler_chain_default_params(); + sparams.no_perf = true; + llama_sampler *smpl = llama_sampler_chain_init(sparams); + + // Grammar first: it masks tokens that would violate the grammar, so the + // selector below only ever picks a valid token. + llama_sampler_chain_add(smpl, grmr); + llama_sampler_chain_add(smpl, llama_sampler_init_penalties(64, 1.1f, 0.0f, 0.0f)); + // Greedy: with the grammar mask in place, pick the most likely valid token. + llama_sampler_chain_add(smpl, llama_sampler_init_greedy()); + + return reinterpret_cast(smpl); +} + extern "C" JNIEXPORT void JNICALL Java_android_llama_cpp_LLamaAndroid_free_1sampler(JNIEnv *, jobject, jlong sampler_pointer) { @@ -632,55 +687,45 @@ Java_android_llama_cpp_LLamaAndroid_completion_1init( g_prompt_tokens = static_cast(tokens_list.size()); for (auto id: tokens_list) { - LOGi("token: `%s`-> %d ", common_token_to_piece(context, id).c_str(), id); + LOGv("token: `%s`-> %d ", common_token_to_piece(context, id).c_str(), id); } common_batch_clear(*batch); - bool reuse = false; - size_t reuse_prefix = 0; + // Reuse the longest common prefix with the cached sequence so the unchanged prefix (system prompt) isn't re-prefilled. + size_t lcp = 0; { std::lock_guard lock(g_globals_mutex); if (g_kv_cache_reuse.load() && !g_cached_tokens.empty()) { - if (g_cached_tokens.size() <= tokens_list.size()) { - reuse = true; - for (size_t i = 0; i < g_cached_tokens.size(); i++) { - if (g_cached_tokens[i] != tokens_list[i]) { - reuse = false; - break; - } - } - if (reuse) { - reuse_prefix = g_cached_tokens.size(); - } + const size_t maxlcp = std::min(g_cached_tokens.size(), tokens_list.size()); + while (lcp < maxlcp && g_cached_tokens[lcp] == tokens_list[lcp]) { + lcp++; } } } - if (!reuse) { - // Fully reset KV cache to avoid non-consecutive sequence positions. - llama_memory_clear(llama_get_memory(context), true); - { - std::lock_guard lock(g_globals_mutex); - if (!g_kv_cache_reuse.load()) { - g_cached_tokens.clear(); - } - g_cached_tokens.assign(tokens_list.begin(), tokens_list.end()); - } - // evaluate the initial prompt - for (auto i = 0; i < tokens_list.size(); i++) { - common_batch_add(*batch, tokens_list[i], i, {0}, false); - } + // Always leave at least one token to decode, so we get logits for the next token. + if (lcp == tokens_list.size() && lcp > 0) { + lcp--; + } + + llama_memory_t mem = llama_get_memory(context); + if (lcp == 0) { + // Nothing reusable — full reset. + llama_memory_clear(mem, true); } else { - { - std::lock_guard lock(g_globals_mutex); - g_cached_tokens.assign(tokens_list.begin(), tokens_list.end()); - } - if (reuse_prefix < tokens_list.size()) { - for (auto i = reuse_prefix; i < tokens_list.size(); i++) { - common_batch_add(*batch, tokens_list[i], i, {0}, false); - } - } + // Evict cached positions past the common prefix. + llama_memory_seq_rm(mem, 0, (llama_pos) lcp, -1); + } + + { + std::lock_guard lock(g_globals_mutex); + g_cached_tokens.assign(tokens_list.begin(), tokens_list.end()); + } + + // Prefill only the divergent tail. + for (size_t i = lcp; i < tokens_list.size(); i++) { + common_batch_add(*batch, tokens_list[i], (llama_pos) i, {0}, false); } if (batch->n_tokens > 0) { @@ -797,11 +842,15 @@ Java_android_llama_cpp_LLamaAndroid_completion_1loop( new_token = new_jstring_utf8(env, cached_token_chars.c_str()); } +#ifndef NDEBUG + // Per-token JNI upcall into the Kotlin logger — debug-only; on the hot + // path in release it would add a JNI round-trip (and a lock) per token. { std::lock_guard lock(g_globals_mutex); log_info_to_kt("cached: %s, new_token_chars: `%s`, id: %d", cached_token_chars.c_str(), new_token_chars.c_str(), new_token_id); } +#endif { std::lock_guard lock(g_globals_mutex); diff --git a/ai-core/llama-impl/src/main/java/android/llama/cpp/LLamaAndroid.kt b/ai-core/llama-impl/src/main/java/android/llama/cpp/LLamaAndroid.kt index 7d0ff4a1..10f73297 100644 --- a/ai-core/llama-impl/src/main/java/android/llama/cpp/LLamaAndroid.kt +++ b/ai-core/llama-impl/src/main/java/android/llama/cpp/LLamaAndroid.kt @@ -94,6 +94,19 @@ class LLamaAndroid : ILlamaController { private val isStopped = AtomicBoolean(false) + /** + * Optional GBNF grammar constraining the next generation(s). When set, [send] + * builds a fresh grammar-constrained sampler per call (grammar state is + * per-generation) and frees it afterward. Null → the plain load-time sampler. + */ + @Volatile + private var grammar: String? = null + + /** Set (or clear, with null) the GBNF grammar used to constrain generation. */ + fun setGrammar(gbnf: String?) { + grammar = gbnf?.takeIf { it.isNotBlank() } + } + @Volatile private var runLoopDispatcher: ExecutorCoroutineDispatcher = createRunLoop() @@ -152,6 +165,7 @@ class LLamaAndroid : ILlamaController { private external fun new_batch(nTokens: Int, embd: Int, nSeqMax: Int): Long private external fun free_batch(batch: Long) private external fun new_sampler(): Long + private external fun new_grammar_sampler(model: Long, grammar: String): Long private external fun free_sampler(sampler: Long) private external fun bench_model( context: Long, @@ -263,28 +277,43 @@ class LLamaAndroid : ILlamaController { kv_cache_clear(state.context) } - val ncur = IntVar( - completion_init( - state.context, - state.batch, - message, - formatChat, - nlen, - stop.toTypedArray() + // A grammar sampler is stateful, so build a fresh one for this + // generation and free it when done. Fall back to the plain + // load-time sampler if there's no grammar or it fails to build. + val grammarText = grammar + val grammarSampler = grammarText?.let { new_grammar_sampler(state.model, it) } + ?.takeIf { it != 0L } + if (grammarText != null && grammarSampler == null) { + log.warn("Grammar sampler failed to build; falling back to the plain sampler") + } + val sampler = grammarSampler ?: state.sampler + + try { + val ncur = IntVar( + completion_init( + state.context, + state.batch, + message, + formatChat, + nlen, + stop.toTypedArray() + ) ) - ) - - while (true) { - if (isStopped.get()) { - log.info("Stopping generation loop because stop flag was set.") - break - } - val str = completion_loop(state.context, state.batch, state.sampler, nlen, ncur) - if (str == null) { - break + while (true) { + if (isStopped.get()) { + log.info("Stopping generation loop because stop flag was set.") + break + } + + val str = completion_loop(state.context, state.batch, sampler, nlen, ncur) + if (str == null) { + break + } + emit(str) } - emit(str) + } finally { + if (grammarSampler != null) free_sampler(grammarSampler) } } diff --git a/ai-core/scripts/rebuild-llama-aar.sh b/ai-core/scripts/rebuild-llama-aar.sh index 848d72e9..5f37d231 100755 --- a/ai-core/scripts/rebuild-llama-aar.sh +++ b/ai-core/scripts/rebuild-llama-aar.sh @@ -11,12 +11,12 @@ set -euo pipefail -# Run from the ai-assistant/ project root regardless of where it's invoked. +# Run from the ai-core/ project root regardless of where it's invoked. cd "$(dirname "$0")/.." -AAR_DST="ai-core-plugin/libs/v8/llama-v8-release.aar" +AAR_DST="libs/v8/llama-v8-release.aar" AAR_SRC="llama-impl/build/outputs/aar/llama-impl-release.aar" -API_DST="ai-core-plugin/libs/llama-api.jar" +API_DST="libs/llama-api.jar" API_SRC="llama-api/build/libs/llama-api.jar" echo "==> Initializing the llama.cpp submodule (source for the native build)" @@ -25,7 +25,7 @@ git submodule update --init --recursive echo "==> Building :llama-impl (native lib) and :llama-api (interface jar)" ./gradlew :llama-impl:assembleRelease :llama-api:jar -echo "==> Copying artifacts into ai-core-plugin/libs" +echo "==> Copying artifacts into ai-core/libs" cp "$AAR_SRC" "$AAR_DST" cp "$API_SRC" "$API_DST" diff --git a/ai-core/src/main/kotlin/com/itsaky/androidide/plugins/aicore/CancellableBackend.kt b/ai-core/src/main/kotlin/com/itsaky/androidide/plugins/aicore/CancellableBackend.kt new file mode 100644 index 00000000..3f992040 --- /dev/null +++ b/ai-core/src/main/kotlin/com/itsaky/androidide/plugins/aicore/CancellableBackend.kt @@ -0,0 +1,11 @@ +package com.itsaky.androidide.plugins.aicore + +/** + * A backend whose in-flight streaming generation can be cancelled (Stop pressed). + * Implement this so [LlmInferenceServiceImpl.cancelGeneration] cancels it without + * a per-type `when` branch. + */ +interface CancellableBackend { + /** Cancel any in-flight generation. */ + fun cancelStreaming() +} diff --git a/ai-core/src/main/kotlin/com/itsaky/androidide/plugins/aicore/GeminiBackend.kt b/ai-core/src/main/kotlin/com/itsaky/androidide/plugins/aicore/GeminiBackend.kt index 081488b8..d1a71d1c 100644 --- a/ai-core/src/main/kotlin/com/itsaky/androidide/plugins/aicore/GeminiBackend.kt +++ b/ai-core/src/main/kotlin/com/itsaky/androidide/plugins/aicore/GeminiBackend.kt @@ -28,7 +28,7 @@ import java.util.concurrent.CompletableFuture * OkHttp (no such overload) — that mismatch crashed generation with a NoSuchMethodError. * HttpURLConnection has no third-party dependency, so it works regardless of the host's OkHttp. */ -class GeminiBackend(private val context: PluginContext) : LlmBackend { +class GeminiBackend(private val context: PluginContext) : LlmBackend, CancellableBackend { private val scope = CoroutineScope(Dispatchers.IO) @@ -390,6 +390,12 @@ User: $userPrompt""" } } + /** Cancel any in-flight generation (user pressed Stop). */ + override fun cancelStreaming() { + currentJob?.cancel() + currentJob = null + } + /** * Release all resources: cancel the backend scope and any in-flight * request. Called from AiCorePlugin.dispose(). diff --git a/ai-core/src/main/kotlin/com/itsaky/androidide/plugins/aicore/LlmInferenceServiceImpl.kt b/ai-core/src/main/kotlin/com/itsaky/androidide/plugins/aicore/LlmInferenceServiceImpl.kt index 19d87b6d..fe3a7f24 100644 --- a/ai-core/src/main/kotlin/com/itsaky/androidide/plugins/aicore/LlmInferenceServiceImpl.kt +++ b/ai-core/src/main/kotlin/com/itsaky/androidide/plugins/aicore/LlmInferenceServiceImpl.kt @@ -168,5 +168,8 @@ class LlmInferenceServiceImpl : LlmInferenceService { override fun cancelGeneration() { currentGeneration?.cancel(true) currentGeneration = null + + backends.values.filterIsInstance() + .forEach { it.cancelStreaming() } } } diff --git a/ai-core/src/main/kotlin/com/itsaky/androidide/plugins/aicore/LocalLlmBackend.kt b/ai-core/src/main/kotlin/com/itsaky/androidide/plugins/aicore/LocalLlmBackend.kt index d9e437a9..59e6be34 100644 --- a/ai-core/src/main/kotlin/com/itsaky/androidide/plugins/aicore/LocalLlmBackend.kt +++ b/ai-core/src/main/kotlin/com/itsaky/androidide/plugins/aicore/LocalLlmBackend.kt @@ -6,26 +6,57 @@ import android.provider.OpenableColumns import com.itsaky.androidide.plugins.services.LlmInferenceService.* import com.itsaky.androidide.plugins.services.SharedServices import com.itsaky.androidide.plugins.PluginContext +import kotlinx.coroutines.CancellationException import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job import kotlinx.coroutines.cancel +import kotlinx.coroutines.ensureActive import kotlinx.coroutines.launch +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock import java.io.File import java.io.FileOutputStream import java.util.concurrent.CompletableFuture +import java.util.concurrent.atomic.AtomicBoolean /** * Local LLM backend using llama-impl for on-device inference. * Wraps llama-impl APIs and implements LlmBackend interface. */ -class LocalLlmBackend(private val context: PluginContext) : LlmBackend { +class LocalLlmBackend(private val context: PluginContext) : LlmBackend, CancellableBackend { + + companion object { + /** + * [LlmConfig.extraParams] key for an optional GBNF grammar. The caller + * owns it (keeping this backend free of any tool vocabulary); absent → + * unconstrained sampling. + */ + const val EXTRA_PARAM_GRAMMAR = "grammar" + + /** + * Stops the model fabricating the next `User:` turn of this backend's own + * scaffold — otherwise an unconstrained model runs on to `maxTokens`. The + * native stop truncates before the match, so it only trims that fabrication. + */ + private val SCAFFOLD_STOP = listOf("\nUser:") + } private val llama by lazy { LLamaAndroid.instance() } private val scope = CoroutineScope(Dispatchers.IO) + /** Single-flight guard serializing whole generations on the shared native context. */ + private val generationMutex = Mutex() + + @Volatile private var currentStreamingJob: Job? = null + @Volatile private var currentGenerateJob: Job? = null + @Volatile private var modelLoaded = false @Volatile private var currentModelPath: String? = null + /** Ensures the background warm-up load is launched at most once. */ + private val warmUpStarted = AtomicBoolean(false) + override fun getId(): String = "local" override fun getName(): String = "Local LLM" @@ -44,10 +75,37 @@ class LocalLlmBackend(private val context: PluginContext) : LlmBackend { context.logger.debug("LocalLlmBackend.isAvailable() - configured path: $configuredPath, modelLoaded: $modelLoaded") + // Chat-open hits this; start loading now so the first message isn't gated on a cold load. + maybeWarmUp(configuredPath, prefs?.getString("ai_backend_preference", "LOCAL_LLM")) + // Available if model is loaded OR if a path is configured return modelLoaded || !configuredPath.isNullOrBlank() } + /** + * Preloads the configured model in the background, once, so the first generation + * doesn't pay the cold-load cost. No-op unless the local backend is the selected one. + * @param configuredPath the configured model path/URI, or null/blank if unset. + * @param backendPreference the `ai_backend_preference` value ("LOCAL_LLM"/"GEMINI"). + */ + private fun maybeWarmUp(configuredPath: String?, backendPreference: String?) { + if (configuredPath.isNullOrBlank() || modelLoaded) return + if (backendPreference == "GEMINI") return // user isn't using the local backend + if (!warmUpStarted.compareAndSet(false, true)) return + + scope.launch { + try { + // Serialize with real generations so a mid-warm-up send just waits for this load. + generationMutex.withLock { ensureModelLoaded(configuredPath) } + context.logger.info("Local model warm-up complete") + } catch (e: Exception) { + // Stay silent (the real send surfaces config errors); allow a later retry. + context.logger.warn("Local model warm-up failed: ${e.message}") + warmUpStarted.set(false) + } + } + } + /** * Resolves the user-selected model reference to a real filesystem path the native * loader can `fopen`. @@ -204,46 +262,59 @@ class LocalLlmBackend(private val context: PluginContext) : LlmBackend { val future = CompletableFuture() - scope.launch { + currentGenerateJob = scope.launch { try { - // Configure sampling (use defaults for topP and topK) - LLamaAndroid.configureSampling( - config.temperature, - 0.9f, // topP default - 40 // topK default - ) - LLamaAndroid.configureMaxTokens(config.maxTokens) - - // Ensure model is loaded - ensureModelLoaded(configuredPath) - - // Build full prompt with system message - val fullPrompt = if (config.systemPrompt != null) { - "${config.systemPrompt}\n\nUser: $prompt\nAssistant:" - } else { - "User: $prompt\nAssistant:" - } + // Serialize against other generations on the shared native context. + generationMutex.withLock { + // Configure sampling (use defaults for topP and topK) + LLamaAndroid.configureSampling( + config.temperature, + 0.9f, // topP default + 40 // topK default + ) + LLamaAndroid.configureMaxTokens(config.maxTokens) + + // Ensure model is loaded + ensureModelLoaded(configuredPath) + + // Build full prompt with system message + val fullPrompt = if (config.systemPrompt != null) { + "${config.systemPrompt}\n\nUser: $prompt\nAssistant:" + } else { + "User: $prompt\nAssistant:" + } - val startTime = System.currentTimeMillis() + val startTime = System.currentTimeMillis() + + // Collect all tokens + val responseBuilder = StringBuilder() + var tokenCount = 0 + + // Unconstrained path: clear any grammar left by a concurrent + // streaming call so completions never inherit a tool-call grammar. + llama.setGrammar(null) + + llama.send( + message = fullPrompt, + formatChat = false, + stop = SCAFFOLD_STOP, + clearCache = false + ).collect { token -> + // Honor cancellation so Stop frees the run loop early. + ensureActive() + responseBuilder.append(token) + tokenCount++ + } - // Collect all tokens - val responseBuilder = StringBuilder() - var tokenCount = 0 + val responseText = responseBuilder.toString() + context.logger.info("Generated response: ${responseText.take(50)}... ($tokenCount tokens)") - llama.send( - message = fullPrompt, - formatChat = false, - stop = emptyList(), - clearCache = false - ).collect { token -> - responseBuilder.append(token) - tokenCount++ + future.complete(LlmResponse.success(responseText, tokenCount, System.currentTimeMillis() - startTime)) } - - val responseText = responseBuilder.toString() - context.logger.info("Generated response: ${responseText.take(50)}... ($tokenCount tokens)") - - future.complete(LlmResponse.success(responseText, tokenCount, System.currentTimeMillis() - startTime)) + } catch (ce: CancellationException) { + context.logger.info("Generation cancelled") + future.completeExceptionally(ce) + throw ce } catch (e: Exception) { context.logger.error("Error during generation", e) if (e is ModelNotConfiguredException || e is IncompatibleModelException) { @@ -274,42 +345,58 @@ class LocalLlmBackend(private val context: PluginContext) : LlmBackend { return } - scope.launch { + currentStreamingJob = scope.launch { try { - // Configure sampling (use defaults for topP and topK) - LLamaAndroid.configureSampling( - config.temperature, - 0.9f, // topP default - 40 // topK default - ) - LLamaAndroid.configureMaxTokens(config.maxTokens) - - // Ensure model is loaded - ensureModelLoaded(configuredPath) - - // Build full prompt with system message - val fullPrompt = if (config.systemPrompt != null) { - "${config.systemPrompt}\n\nUser: $prompt\nAssistant:" - } else { - "User: $prompt\nAssistant:" - } - - val startTime = System.currentTimeMillis() - var tokenCount = 0 - val responseBuilder = StringBuilder() - - llama.send( - message = fullPrompt, - formatChat = false, - stop = emptyList(), - clearCache = false - ).collect { token -> - callback.onToken(token) - responseBuilder.append(token) - tokenCount++ + // Hold the single-flight lock for the whole streaming generation. + generationMutex.withLock { + try { + // Configure sampling (use defaults for topP and topK) + LLamaAndroid.configureSampling( + config.temperature, + 0.9f, // topP default + 40 // topK default + ) + LLamaAndroid.configureMaxTokens(config.maxTokens) + + // Ensure model is loaded + ensureModelLoaded(configuredPath) + + // Build full prompt with system message + val fullPrompt = if (config.systemPrompt != null) { + "${config.systemPrompt}\n\nUser: $prompt\nAssistant:" + } else { + "User: $prompt\nAssistant:" + } + + val startTime = System.currentTimeMillis() + var tokenCount = 0 + val responseBuilder = StringBuilder() + + // Apply the caller's grammar for this send() only; reset in the finally. + val grammar = config.extraParams?.get(EXTRA_PARAM_GRAMMAR) as? String + llama.setGrammar(grammar?.takeIf { it.isNotBlank() }) + + llama.send( + message = fullPrompt, + formatChat = false, + // Keep the KV cache so the native layer reuses the common prefix (system prompt). + stop = SCAFFOLD_STOP, + clearCache = false + ).collect { token -> + ensureActive() + callback.onToken(token) + responseBuilder.append(token) + tokenCount++ + } + + callback.onComplete(LlmResponse.success(responseBuilder.toString(), tokenCount, System.currentTimeMillis() - startTime)) + } finally { + llama.setGrammar(null) + } } - - callback.onComplete(LlmResponse.success(responseBuilder.toString(), tokenCount, System.currentTimeMillis() - startTime)) + } catch (ce: CancellationException) { + context.logger.info("Streaming generation cancelled") + throw ce } catch (e: Exception) { context.logger.error("Error during streaming generation", e) if (e is ModelNotConfiguredException || e is IncompatibleModelException) { @@ -320,6 +407,17 @@ class LocalLlmBackend(private val context: PluginContext) : LlmBackend { } } + /** + * Cancels any in-flight streaming or non-streaming generation (user pressed Stop), + * cancelling the coroutine Job so the single-threaded run loop is freed early. + */ + override fun cancelStreaming() { + currentStreamingJob?.cancel() + currentStreamingJob = null + currentGenerateJob?.cancel() + currentGenerateJob = null + } + override fun generateWithHistory( history: List, prompt: String,