diff --git a/plugins/AI-Core/src/main/kotlin/com/itsaky/androidide/plugins/aicore/tool/Executor.kt b/plugins/AI-Core/src/main/kotlin/com/itsaky/androidide/plugins/aicore/tool/Executor.kt index 1ee5fbf2..f8b038d6 100644 --- a/plugins/AI-Core/src/main/kotlin/com/itsaky/androidide/plugins/aicore/tool/Executor.kt +++ b/plugins/AI-Core/src/main/kotlin/com/itsaky/androidide/plugins/aicore/tool/Executor.kt @@ -58,13 +58,22 @@ class Executor( private val VERBATIM_TEXT_ARGS = setOf("old_string") /** - * Whether [value] fails the required-argument check for [key]. + * Required args where an empty string is a legal value, so only an absent argument is + * missing: `create_file` with `content = ""` is an empty file, and a small model asked for + * one sends exactly that. + */ + private val EMPTY_ALLOWED_ARGS = setOf("create_file" to "content") + + /** + * Whether [value] fails the required-argument check for [key] of [toolName]. + * @param toolName the tool being dispatched. * @param key the required argument's name. * @param value the supplied value, or null when absent. * @return true when the argument must be reported missing. */ - private fun isMissing(key: String, value: Any?): Boolean { + private fun isMissing(toolName: String, key: String, value: Any?): Boolean { val text = value?.toString() + if ((toolName to key) in EMPTY_ALLOWED_ARGS) return text == null return if (key in VERBATIM_TEXT_ARGS) text.isNullOrEmpty() else text?.trim().isNullOrEmpty() } } @@ -163,7 +172,7 @@ class Executor( // Check required arguments val missingArgs = requiredArgsForTool(toolName).filter { key -> - isMissing(key, normalizedArgs[key]) + isMissing(toolName, key, normalizedArgs[key]) } if (missingArgs.isNotEmpty()) { val message = "Missing required argument(s): ${missingArgs.joinToString(", ")}" diff --git a/plugins/AI-Core/src/test/kotlin/com/itsaky/androidide/plugins/aicore/tool/ExecutorTest.kt b/plugins/AI-Core/src/test/kotlin/com/itsaky/androidide/plugins/aicore/tool/ExecutorTest.kt index 073db873..bbb94a8c 100644 --- a/plugins/AI-Core/src/test/kotlin/com/itsaky/androidide/plugins/aicore/tool/ExecutorTest.kt +++ b/plugins/AI-Core/src/test/kotlin/com/itsaky/androidide/plugins/aicore/tool/ExecutorTest.kt @@ -199,6 +199,46 @@ class ExecutorTest { assertTrue("a deletion must reach the handler", results.single().success) } + private class RecordingCreateFile : ToolHandler { + override val toolName = "create_file" + override val description = "fake create" + override val requiresApproval = false + override val pathArgs = listOf("file_path") + var seenArgs: Map? = null + private set + + override suspend fun execute(args: Map): ToolResult { + seenArgs = args + return ToolResult.success("created") + } + } + + @Test + fun givenCreateFileWithEmptyContent_whenExecuting_thenItReachesTheHandler() = runBlocking { + // An empty file is a normal request, and a small model asked for one sends content = "". + val handler = RecordingCreateFile() + + val results = executorFor(handler).execute( + listOf(ToolCall("create_file", mapOf("file_path" to "Util.kt", "content" to ""))) + ) + + assertTrue("empty content must not read as missing: ${results.single().message}", results.single().success) + assertEquals("", handler.seenArgs?.get("content")) + } + + @Test + fun givenCreateFileWithNoContentArgument_whenExecuting_thenItIsRejectedBeforeTheHandlerRuns() = runBlocking { + val handler = RecordingCreateFile() + + val results = executorFor(handler).execute( + listOf(ToolCall("create_file", mapOf("file_path" to "Util.kt"))) + ) + + assertFalse(results.single().success) + assertTrue(results.single().message.contains("content")) + assertEquals(null, handler.seenArgs) + } + @Test fun givenAWhitespaceOnlyOldString_whenExecuting_thenItReachesTheHandler() = runBlocking { // Re-indenting is a legal edit whose old_string is whitespace, not a missing argument.