Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,12 @@ import org.json.JSONObject
*/
private const val TAG = "$LOG_PREFIX.AgentTrace"

/** How much of a refusal's body the trace keeps: enough for the field and the reason it names. */
private const val REFUSAL_BODY_PREVIEW_CHARS = 500

/** One model on one server, the unit a tool refusal is remembered for. */
private data class ServerModel(val baseUrl: String, val model: String)

/**
* OpenAI-compatible backend: one transport for every server that speaks `chat/completions`.
*
Expand Down Expand Up @@ -111,11 +117,11 @@ class OpenAiBackend(
private var currentJob: Job? = null

/**
* Base URL that answered a tool declaration with a refusal, so the next turn does not pay the
* same round trip. Keyed by the URL itself, so pointing the setting elsewhere re-probes.
* The server and model that answered a tool declaration with a refusal, so the next turn does
* not pay the same round trip. Keyed by both: one model refusing tools says nothing of the next.
*/
@Volatile
private var toolsRejectedBy: String? = null
private var toolsRejectedBy: ServerModel? = null

/**
* Vector length this server actually returned, as (embedding model -> dimensions).
Expand Down Expand Up @@ -829,7 +835,8 @@ class OpenAiBackend(
* Run [attempt] with [tools] declared and, if this server refuses a tool declaration, run it
* once more with none.
*
* The refusal is remembered per server so only the first turn pays for it. What it costs is
* The refusal is remembered per server and model so only the first turn pays for it, and
* picking another model tries tools again. What it costs is
* real: the system prompt for this run was built for native calling, so it teaches no envelope
* and the model has no other way to reach a tool — the turn answers in prose. Servers that
* take `tools` are the overwhelming majority, and this keeps the rest chatting rather than
Expand All @@ -842,8 +849,8 @@ class OpenAiBackend(
tools: List<ToolDefinition>,
attempt: suspend (List<ToolDefinition>) -> Unit
) {
val baseUrl = getBaseUrl()
if (tools.isEmpty() || toolsRejectedBy == baseUrl) {
val target = ServerModel(getBaseUrl(), getModelName())
if (tools.isEmpty() || toolsRejectedBy == target) {
Comment thread
jatezzz marked this conversation as resolved.
Outdated
attempt(emptyList())
return
}
Expand All @@ -853,16 +860,21 @@ class OpenAiBackend(
throw e
} catch (e: OpenAiHttpException) {
if (!UnsupportedTools.rejectedIn(e.statusCode, e.body)) throw e
toolsRejectedBy = baseUrl
Log.w(TAG, "REQUEST | server refused a tool declaration; retrying with none")
toolsRejectedBy = target
// The body names what was refused; without it a refused model and a refused schema look alike.
Log.w(
TAG,
"REQUEST | ${target.model} refused a tool declaration; retrying with none | " +
e.body.orEmpty().take(REFUSAL_BODY_PREVIEW_CHARS)
)
context.logger.warn(
"OpenAiBackend: $baseUrl does not accept tool declarations; " +
"the agent cannot call tools on this server"
"OpenAiBackend: ${target.model} on ${target.baseUrl} does not accept tool " +
"declarations; the agent cannot call tools with it"
)
// Said out loud, not only logged: from here the agent answers but never touches the
// project, which reads as the tools being broken. Once per server, since the flag
// above short-circuits every later turn.
notifyToolsUnsupported(baseUrl)
// project, which reads as the tools being broken. Once per server and model, since
// the flag above short-circuits every later turn.
notifyToolsUnsupported(target)
attempt(emptyList())
}
}
Expand All @@ -871,11 +883,11 @@ class OpenAiBackend(
* Tells the user this server cannot call tools, as a Toast: the run continues, so there is no
* error message to carry it, and the chat's own turn is an ordinary prose answer.
*
* @param baseUrl the server that refused, named in the message.
* @param target the server and model that refused, both named in the message.
*/
private fun notifyToolsUnsupported(baseUrl: String) {
private fun notifyToolsUnsupported(target: ServerModel) {
val appContext = context.androidContext.applicationContext
val message = appContext.getString(R.string.openai_error_tools_unsupported, baseUrl)
val message = appContext.getString(R.string.openai_error_tools_unsupported, target.model, target.baseUrl)
Handler(Looper.getMainLooper()).post {
Toast.makeText(appContext, message, Toast.LENGTH_LONG).show()
}
Expand Down
2 changes: 1 addition & 1 deletion plugins/AI-Agent-OpenAI/src/main/res/values/strings.xml
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@
<string name="openai_error_empty_reply">The server answered but sent no reply text. Check the model is fully loaded in your server, then try again — see the IDE log for what the server sent.</string>
<string name="openai_error_reasoning_only">The model spent its whole reply on internal reasoning and never answered. Raise the response length limit in your server, or choose a model without a thinking mode.</string>
<string name="openai_error_truncated">The reply was cut off before any text arrived — the response length limit is too low for this model. Raise it in your server settings.</string>
<string name="openai_error_tools_unsupported">%1$s does not support tool calling, so the AI can answer but cannot change your project. Point the server setting at one that does.</string>
<string name="openai_error_tools_unsupported">%1$s on %2$s does not support tool calling, so the AI can answer but cannot use its tools. Pick another model.</string>
<string name="openai_error_failed">The request to the AI server failed.</string>
<string name="openai_error_failed_reason">The request to the AI server failed. %1$s</string>

Expand Down
9 changes: 5 additions & 4 deletions plugins/AI-Core/ai-core.html
Original file line number Diff line number Diff line change
Expand Up @@ -62,8 +62,8 @@ <h2>Core functionality</h2>
<ul>
<li><b>Agent chat</b> — a conversational editor tab that can read and edit
project files, add dependencies, run a Gradle sync, list and run any Gradle
task (tests, lint, clean, your own) and launch the app, with every file-changing action
gated behind an approval dialog.</li>
task (tests, lint, clean, your own), run a shell command in the Terminal and
launch the app, with every file-changing action gated behind an approval dialog.</li>
<li><b>Agent settings</b> — one screen to pick the backend and configure it;
each backend contributes its own portion of that screen.</li>
<li><b>Web search</b> — the agent always has <code>web_search</code> (the
Expand Down Expand Up @@ -140,7 +140,8 @@ <h2>Technical architecture</h2>
<p>AI Core declares <b>filesystem.read</b>, <b>filesystem.write</b>,
<b>system.commands</b> and <b>project.structure</b> — the agent reads and edits
files in the open project, triggers Gradle sync, Gradle tasks and run through
the IDE's own build service, and inspects the project's module structure. It declares
the IDE's own build service, runs shell commands in the IDE's Terminal, and
inspects the project's module structure. It declares
<b>no network access and loads no native code</b>: those belong to the backend
Comment thread
jatezzz marked this conversation as resolved.
Outdated
plugin that serves a given request, so a device using only the local backend
never grants a network-capable AI plugin. The same holds for tools another
Expand All @@ -158,7 +159,7 @@ <h2>Usage</h2>
enter a Gemini API key. The same screen is reachable from the Agent tab.</li>
<li>Open a project and switch to the <b>Agent</b> tab to start chatting. Any
action that changes a file asks for your approval first, as does starting a
Gradle sync or task, or generating from a template. Only reads run unprompted.</li>
Gradle sync or task, running a shell command, or generating from a template. Only reads run unprompted.</li>
<li><i>Optional:</i> install a tool provider such as <b>AI Agent MCP</b> to
give the agent tools beyond its own. Its tools appear in the agent's tool
list once configured, and each asks for approval naming the plugin it came
Expand Down
4 changes: 2 additions & 2 deletions plugins/AI-Core/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -15,8 +15,8 @@ android {
applicationId = "com.itsaky.androidide.plugins.aicore"
minSdk = 33
targetSdk = 36
versionCode = 7
versionName = "3.3.0"
versionCode = 8
versionName = "3.4.0"
}

buildFeatures {
Expand Down
11 changes: 9 additions & 2 deletions plugins/AI-Core/src/main/assets/docs/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -111,6 +111,13 @@ <h2>What the agent can do</h2>
<li>List the project's Gradle tasks, your own custom tasks included, with
each task's group and description as of the last sync, so it runs a task
that exists. A task you just added appears after the next Gradle sync.</li>
<li>Run a shell command or script with bash. It asks first, every time, runs it in the
<b>Terminal</b> so you can see what ran, and reads back the exit code and
output. Commands share one Terminal session. A command still running after
30 seconds, such as a server, is left running, and the next command opens a
second session beside it; the agent can check on it later without asking, and
asks before stopping it, touching only its own sessions. A command starts in the project folder;
pressing Stop interrupts it.</li>
<li>Read <b>App Logs</b> and <b>IDE Logs</b>, so it can find the exception
behind a crash without you copying log lines into the chat. It never
asks first: reading a log changes nothing.</li>
Expand Down Expand Up @@ -158,8 +165,8 @@ <h2>Tools from other plugins</h2>
the plugin that supplied one knows what it does:</p>
<ul>
<li><b>They ask every time.</b> The approval dialog names the plugin the tool
came from — <i>From MCP servers</i>, for example — and <b>Always Allow</b>
does not apply to them, however you answer. A plugin cannot waive this for
came from — <i>From MCP servers</i>, for example — and does not offer
<b>Always Allow</b>. A plugin cannot waive this for
its own tools, and the title shows the name the agent registered rather than
the one the tool came with, so a remote tool cannot pass itself off as a
built-in one.</li>
Expand Down
29 changes: 29 additions & 0 deletions plugins/AI-Core/src/main/assets/prompts/tool_descriptions.yml
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,35 @@ built_in_tools:
filter: >-
Text to search task paths, groups and descriptions for, e.g. ":app", "test" or "lint".
Omit to list the tasks that have a group or a description.
run_shell_command:
# Without the second sentence models hand the user a script to run themselves (ADFA-6339);
# without the third, Gemini ran "ls -la" when asked for "ls".
description: >-
Comment thread
jatezzz marked this conversation as resolved.
Run a shell command or script with bash in the IDE's Terminal and get back its exit code and
output. Use it instead of asking the user to run a command. When the user gives the command,
run it exactly as written, adding no flags or options they did not ask for. A command still
running after 30 seconds, such as a server, is left running and its output so far comes
back. For a Gradle task use run_gradle_task, not ./gradlew
arguments:
command: >-
The command or script to run with bash, e.g. "ls -la app/src" or several lines of script.
working_directory: >-
Project-relative directory to run in; it must be inside the project. Omit to run at the
project root.
read_terminal_session:
description: >-
Check on a command run_shell_command left running, such as a server: whether it still runs,
its exit code once it stopped, and its latest output
arguments:
session: The Terminal session name the run_shell_command result reported.
stop_terminal_session:
# Its second sentence: Gemini stopped a ping it had just started, to finish the request.
description: >-
Stop a command run_shell_command left running, such as a server or ping, with Ctrl-C. Use it
only when the user asks to stop, kill or cancel that command, never to finish a request that
started it
arguments:
session: The Terminal session name the run_shell_command result reported.
generate_from_template:
description: Generate files from Pebble templates with variable substitution
arguments:
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package com.itsaky.androidide.plugins.aicore.fragments

import android.app.Dialog
import android.content.res.Resources
import android.os.Bundle
import android.text.InputType
import android.view.View
Expand All @@ -13,14 +14,13 @@ import com.itsaky.androidide.plugins.aicore.plugin.AiCorePlugin
import com.itsaky.androidide.plugins.aicore.R
import com.itsaky.androidide.plugins.aicore.tool.ApprovalRequest
import com.itsaky.androidide.plugins.aicore.tool.ApprovalResult
import com.itsaky.androidide.plugins.aicore.tool.handlers.EditFileHandler
import com.itsaky.androidide.plugins.base.PluginFragmentHelper
import com.itsaky.androidide.plugins.services.IdeTooltipService

/**
* Dialog for approving tool execution; for an edit, a real review step — a before/after block with
* **Accept / Correct / Decline** and no blanket "Always Allow". Decisions go to [Host], resolved
* from [getParentFragment] each time, since a captured callback dies on recreation.
* Dialog for approving tool execution; for an edit, a before/after block with **Accept / Correct /
* Decline**. "Always Allow" shows only when the request allows a session grant. Decisions go
* to [Host], resolved from [getParentFragment] each time, since a captured callback dies on recreation.
*/
class ApprovalDialogFragment : DialogFragment() {

Expand Down Expand Up @@ -57,15 +57,17 @@ class ApprovalDialogFragment : DialogFragment() {
private const val ARG_SOURCE = "source"
private const val ARG_DESCRIPTION = "description"
private const val ARG_ARGS = "args"
private const val ARG_IS_EDIT = "is_edit"
private const val ARG_KIND = "kind"
private const val ARG_ALLOWS_ALWAYS_ALLOW = "allows_always_allow"

/**
* Builds the dialog. Everything it needs is in [getArguments], so the framework can
* recreate it after a configuration change without losing the decision channel.
* @param request the pending approval to render.
* @param resources words the labels inside the formatted arguments.
*/
fun newInstance(request: ApprovalRequest): ApprovalDialogFragment {
val isEdit = request.toolName == EditFileHandler.TOOL_NAME
fun newInstance(request: ApprovalRequest, resources: Resources): ApprovalDialogFragment {
val kind = ApprovalKind.of(request.preview)
return ApprovalDialogFragment().apply {
arguments = Bundle().apply {
// The registered name, not the provider's: it is the tool that will actually
Expand All @@ -77,12 +79,9 @@ class ApprovalDialogFragment : DialogFragment() {
)
putString(ARG_SOURCE, request.sourceLabel)
putString(ARG_DESCRIPTION, request.description)
putBoolean(ARG_IS_EDIT, isEdit)
putString(
ARG_ARGS,
if (isEdit) ApprovalTextFormatter.formatEdit(request.args)
else ApprovalTextFormatter.formatArgs(request.args)
)
putString(ARG_KIND, kind.name)
putString(ARG_ARGS, kind.format(request.args, resources))
putBoolean(ARG_ALLOWS_ALWAYS_ALLOW, request.allowsSessionApproval)
}
}
}
Expand All @@ -94,7 +93,9 @@ class ApprovalDialogFragment : DialogFragment() {
val source = arguments?.getString(ARG_SOURCE)
val description = arguments?.getString(ARG_DESCRIPTION) ?: ""
val argsText = arguments?.getString(ARG_ARGS) ?: "{}"
val isEdit = arguments?.getBoolean(ARG_IS_EDIT) == true
val kind = ApprovalKind.named(arguments?.getString(ARG_KIND))
val isEdit = kind == ApprovalKind.EDIT
val allowsAlwaysAllow = arguments?.getBoolean(ARG_ALLOWS_ALWAYS_ALLOW) ?: false

val message = buildString {
append(getString(R.string.approval_header))
Expand All @@ -114,7 +115,7 @@ class ApprovalDialogFragment : DialogFragment() {
}
append(description)
append("\n\n")
append(getString(if (isEdit) R.string.approval_proposed_change else R.string.approval_args))
append(getString(kind.argsLabel))
append("\n")
append(argsText)
}
Expand Down Expand Up @@ -142,9 +143,11 @@ class ApprovalDialogFragment : DialogFragment() {
decide(ApprovalResult.APPROVED_ONCE)
dismiss()
}
builder.setNeutralButton(getString(R.string.approval_always_allow)) { _, _ ->
decide(ApprovalResult.APPROVED_FOR_SESSION)
dismiss()
if (allowsAlwaysAllow) {
Comment thread
jatezzz marked this conversation as resolved.
builder.setNeutralButton(getString(R.string.approval_always_allow)) { _, _ ->
decide(ApprovalResult.APPROVED_FOR_SESSION)
dismiss()
}
}
}

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
package com.itsaky.androidide.plugins.aicore.fragments

import android.content.res.Resources
import androidx.annotation.StringRes
import com.itsaky.androidide.plugins.aicore.R
import com.itsaky.androidide.plugins.aicore.tool.ApprovalPreview

/**
* How [ApprovalDialogFragment] presents a pending tool call, one per [ApprovalPreview] the tool's
* handler declares. Whether "Always Allow" is offered is the request's, not the kind's.
*
* @property argsLabel the heading above the formatted arguments.
*/
internal enum class ApprovalKind(@StringRes val argsLabel: Int) {
Comment thread
jatezzz marked this conversation as resolved.
Outdated
EDIT(R.string.approval_proposed_change) {
override fun format(args: Map<String, Any?>, resources: Resources) =
ApprovalTextFormatter.formatEdit(args)
},
SHELL_COMMAND(R.string.approval_command) {
override fun format(args: Map<String, Any?>, resources: Resources) =
ApprovalTextFormatter.formatShellCommand(args) { directory ->
resources.getString(R.string.approval_working_directory, directory)
}
},
OTHER(R.string.approval_args) {
override fun format(args: Map<String, Any?>, resources: Resources) =
ApprovalTextFormatter.formatArgs(args)
};

/** The call's arguments as the dialog shows them; [resources] words any label among them. */
abstract fun format(args: Map<String, Any?>, resources: Resources): String

companion object {
/** The kind that renders [preview]. */
fun of(preview: ApprovalPreview): ApprovalKind = when (preview) {
ApprovalPreview.EDIT -> EDIT
ApprovalPreview.SHELL_COMMAND -> SHELL_COMMAND
ApprovalPreview.ARGS -> OTHER
}

/** The kind saved as [name] in the dialog's arguments; [OTHER] when it is missing or unknown. */
fun named(name: String?): ApprovalKind = entries.firstOrNull { it.name == name } ?: OTHER
}
}
Loading
Loading