Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
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 @@ -44,6 +44,7 @@ import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch
import org.json.JSONArray
import org.json.JSONObject
import java.util.concurrent.ConcurrentHashMap

/**
* Tool-protocol tracing, under the tag suffix `ai-core` uses for the other half of the same run:
Expand All @@ -54,6 +55,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 +118,10 @@ 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.
* Every server and model that answered a tool declaration with a refusal, so a later 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 val toolsRejectedBy: MutableSet<ServerModel> = ConcurrentHashMap.newKeySet()

/**
* 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() || target in toolsRejectedBy) {
attempt(emptyList())
return
}
Expand All @@ -853,16 +860,23 @@ 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.add(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)
)
// The toast sends the user here, so this line carries what the server named too.
context.logger.warn(
"OpenAiBackend: $baseUrl does not accept tool declarations; " +
"the agent cannot call tools on this server"
"OpenAiBackend: ${target.model} on ${target.baseUrl} refused the tool " +
"declarations; the agent cannot call tools with it | " +
e.body.orEmpty().take(REFUSAL_BODY_PREVIEW_CHARS)
)
// 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 set above short-circuits every later turn.
notifyToolsUnsupported(target)
attempt(emptyList())
}
}
Expand All @@ -871,11 +885,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 refused the agent\'s tools, so the AI can answer but cannot use them. The model may not support tool calling, or the server may reject one tool\'s definition; the IDE log says which.</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
24 changes: 12 additions & 12 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,10 +140,11 @@ <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
<b>no network access and loads no native code</b>: those belong to the backend
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
the IDE's own build service, runs shell commands in the IDE's Terminal, and
inspects the project's module structure. It also declares
<b>network.access</b>, for <code>fetch_url</code>, which reads a page or file only
after you approve it. It <b>loads no native code</b>: that belongs to the backend
plugin that serves a given request. The same holds for tools another
plugin contributes — they run inside the contributing plugin, under the
permissions <i>it</i> declared, which is why the approval dialog names the
plugin a tool came from.</p>
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 All @@ -175,11 +176,10 @@ <h2>Key benefits</h2>
<li><b>One router, many plugins</b> — a single inference service shared
across all AI features, so backend and model choice are configured once.</li>
<li><b>Install only what you need</b> — a device that only ever uses the
cloud backend need not carry an 8&nbsp;MB native library, and one that never
goes online need not carry a network-capable plugin at all.</li>
<li><b>Least privilege</b> — network access, filesystem access and native
code are declared by the specific backend that needs them, so what a user
grants matches what they installed.</li>
cloud backend need not carry an 8&nbsp;MB native library.</li>
<li><b>Least privilege</b> — native code and a provider's API access are
declared by the specific backend that needs them, so what a user grants
matches what they installed.</li>
<li><b>Extensible in two directions</b> — a new model provider is a new
backend plugin, and a new agent capability is a new tool source. Neither
requires a change to AI Core or to any consumer plugin.</li>
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
12 changes: 10 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,14 @@ <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 reuse an idle Terminal session, but each runs in a fresh shell, so a
<code>cd</code> or <code>export</code> does not carry over to the next command. A command still running after
30 seconds, such as a server, is left running, and the next command opens
another session beside it; the agent can check on it later without asking, and
asks before stopping it, touching only its own commands. 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 +166,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
33 changes: 33 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,39 @@ 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". The host runs each call in a
# fresh bash, so a lone "cd" or "export" is lost before the next call.
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. Each call starts
a new shell, so cd and export do not carry over to the next call: use working_directory, or
chain in one call, e.g. "cd app && ls src". A non-zero exit code can be the answer, e.g.
grep exits 1 when nothing matches. 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_command:
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:
command_id: The command id the run_shell_command result reported.
stop_terminal_command:
# 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:
command_id: The command id the run_shell_command result reported.
generate_from_template:
description: Generate files from Pebble templates with variable substitution
arguments:
Expand Down
Loading
Loading