From 62b76569842ab13f05d1d952b9ec005fc72bdd95 Mon Sep 17 00:00:00 2001 From: John Trujillo Date: Wed, 30 Sep 2026 10:00:17 -0500 Subject: [PATCH 1/4] fix(ai-agent): answer general questions and finish multi-part requests [ADFA-6223] Stop refusing general and off-domain questions, and keep working through multi-part requests. - Move the system prompts, agent loop and tool wording for ai-core and the three backends into YAML; backends take their prompt config as a constructor argument, and ai-core runs its activation checks through PromptConfigChecks. - Make web search, fetch_url and the device clock always available, and remove the Web search switch. - End tool runs through respond, fix add_dependency paths, and cite only real sources. Check code-bearing answers in a second review pass that verifies API claims against the evidence. - ToolCallExtractor ignores bare {"tool":...} JSON inside code fences, closed or not. - Web search: a GroundingRedirect on a non-HTTP link or unchecked failure is cited as given rather than failing the search; OpenAI throws on a 2xx reply carrying an error object, and 402 and insufficient_quota classify as BillingRequired. OpenAI read timeout is 180s, reported as TimedOut. - EXAMPLE_FILE_STEM falls back to the full name for dotfiles. - Use plugin-api's shared prompt engine, YAML loader, store and pane helpers (ADFA-6281); each plugin keeps only its config type, PromptConfigParser and sharedPromptConfig store. snakeyaml moves to testImplementation. --- plugins/AI-Agent-Gemini/README.md | 46 +- plugins/AI-Agent-Gemini/build.gradle.kts | 8 + .../src/main/assets/prompts/agent.yml | 23 + .../src/main/assets/prompts/layout.yml | 55 ++ .../src/main/assets/prompts/rules.yml | 48 ++ .../src/main/assets/prompts/scope.yml | 63 +++ .../src/main/assets/prompts/tools.yml | 36 ++ .../src/main/assets/prompts/workflow.yml | 32 ++ .../aiagentgemini/backend/GeminiBackend.kt | 160 ++++-- .../backend/GeminiToolProtocol.kt | 21 + .../aiagentgemini/backend/GeminiWebSearch.kt | 86 ++++ .../backend/GroundingRedirect.kt | 58 +++ .../aiagentgemini/backend/NetworkTags.kt | 3 + .../errors/GeminiErrorFormatter.kt | 8 + .../aiagentgemini/plugin/GeminiPlugin.kt | 57 +- .../prompt/GeminiPromptVariables.kt | 108 ++++ .../prompt/GeminiSystemPrompt.kt | 127 ++--- .../prompt/config/GeminiPromptConfig.kt | 90 ++++ .../prompt/config/GeminiPromptConfigParser.kt | 64 +++ .../prompt/config/SharedPromptConfig.kt | 8 + .../settings/GeminiSettingsFragment.kt | 41 +- .../plugins/aiagentgemini/ui/PaneStyling.kt | 77 --- .../ui/SecretRevealController.kt | 106 ---- .../src/main/res/values/strings.xml | 6 +- .../backend/GeminiBackendTest.kt | 94 +++- .../backend/GeminiWebSearchTest.kt | 108 ++++ .../backend/GroundingRedirectTest.kt | 100 ++++ .../errors/GeminiCredentialProblemTest.kt | 2 + .../prompt/GeminiSystemPromptTest.kt | 182 ++++++- .../config/DirectoryPromptConfigSource.kt | 46 ++ .../config/GeminiPromptConfigParserTest.kt | 113 ++++ .../prompt/config/ShippedPromptFilesTest.kt | 55 ++ plugins/AI-Agent-Local/README.md | 42 +- plugins/AI-Agent-Local/build.gradle.kts | 3 + .../src/main/assets/prompts/agent.yml | 21 + .../src/main/assets/prompts/layout.yml | 32 ++ .../src/main/assets/prompts/rules.yml | 38 ++ .../src/main/assets/prompts/tools.yml | 27 + .../aiagentlocal/backend/LocalLlmBackend.kt | 37 +- .../aiagentlocal/plugin/LocalLlmPlugin.kt | 57 +- .../prompt/LocalPromptVariables.kt | 82 +++ .../aiagentlocal/prompt/LocalSystemPrompt.kt | 98 ++-- .../prompt/config/LocalPromptConfig.kt | 63 +++ .../prompt/config/LocalPromptConfigParser.kt | 53 ++ .../prompt/config/SharedPromptConfig.kt | 8 + .../backend/LocalLlmBackendTest.kt | 17 +- .../prompt/LocalSystemPromptTest.kt | 176 +++++++ .../config/DirectoryPromptConfigSource.kt | 46 ++ .../config/LocalPromptConfigParserTest.kt | 98 ++++ .../prompt/config/ShippedPromptFilesTest.kt | 55 ++ .../settings/McpSettingsFragment.kt | 10 +- .../aiagentmcp/ui/SecretRevealController.kt | 111 ---- plugins/AI-Agent-OpenAI/README.md | 43 +- plugins/AI-Agent-OpenAI/build.gradle.kts | 8 + .../src/main/assets/prompts/agent.yml | 23 + .../src/main/assets/prompts/layout.yml | 56 ++ .../src/main/assets/prompts/rules.yml | 44 ++ .../src/main/assets/prompts/scope.yml | 63 +++ .../src/main/assets/prompts/tools.yml | 40 ++ .../src/main/assets/prompts/workflow.yml | 32 ++ .../aiagentopenai/backend/OpenAiBackend.kt | 58 ++- .../aiagentopenai/backend/OpenAiHttpClient.kt | 21 +- .../backend/OpenAiRequestBuilder.kt | 4 +- .../backend/OpenAiToolProtocol.kt | 19 + .../aiagentopenai/backend/OpenAiWebSearch.kt | 78 +++ .../aiagentopenai/backend/RequestTuning.kt | 6 + .../errors/OpenAiErrorFormatter.kt | 14 + .../errors/OpenAiFailureMessages.kt | 3 + .../errors/OpenAiReplyException.kt | 11 + .../errors/OpenAiTimeoutException.kt | 17 + .../aiagentopenai/plugin/OpenAiPlugin.kt | 57 +- .../prompt/OpenAiPromptVariables.kt | 110 ++++ .../prompt/OpenAiSystemPrompt.kt | 131 ++--- .../prompt/config/OpenAiPromptConfig.kt | 92 ++++ .../prompt/config/OpenAiPromptConfigParser.kt | 65 +++ .../prompt/config/SharedPromptConfig.kt | 8 + .../aiagentopenai/settings/BaseUrlPolicy.kt | 11 + .../settings/OpenAiSettingsFragment.kt | 41 +- .../plugins/aiagentopenai/ui/PaneStyling.kt | 77 --- .../ui/SecretRevealController.kt | 106 ---- .../src/main/res/values/strings.xml | 2 + .../backend/OpenAiBackendTest.kt | 10 +- .../backend/OpenAiRequestBuilderTest.kt | 43 ++ .../backend/OpenAiWebSearchTest.kt | 114 ++++ .../backend/RequestTuningTest.kt | 11 + .../errors/OpenAiCredentialProblemTest.kt | 1 + .../errors/OpenAiErrorFormatterTest.kt | 15 + .../prompt/OpenAiSystemPromptTest.kt | 136 ++++- .../config/DirectoryPromptConfigSource.kt | 46 ++ .../config/OpenAiPromptConfigParserTest.kt | 113 ++++ .../prompt/config/ShippedPromptFilesTest.kt | 55 ++ .../settings/BaseUrlPolicyTest.kt | 13 + plugins/AI-Core/README.md | 129 +++++ plugins/AI-Core/ai-core.html | 5 + plugins/AI-Core/build.gradle.kts | 8 + plugins/AI-Core/src/main/AndroidManifest.xml | 8 +- .../AI-Core/src/main/assets/docs/index.html | 25 + .../AI-Core/src/main/assets/prompts/agent.yml | 27 + .../src/main/assets/prompts/agent_loop.yml | 52 ++ .../src/main/assets/prompts/answer_review.yml | 28 + .../src/main/assets/prompts/chat_title.yml | 8 + .../src/main/assets/prompts/context_files.yml | 5 + .../src/main/assets/prompts/ide_context.yml | 24 + .../src/main/assets/prompts/layout.yml | 104 ++++ .../AI-Core/src/main/assets/prompts/rules.yml | 23 + .../main/assets/prompts/tool_descriptions.yml | 89 ++++ .../AI-Core/src/main/assets/prompts/tools.yml | 10 + .../src/main/assets/prompts/web_search.yml | 13 + .../plugins/aicore/backends/AiBackend.kt | 8 +- .../plugins/aicore/models/ChatMessage.kt | 5 + .../plugins/aicore/models/ChatTranscript.kt | 32 +- .../plugins/aicore/plugin/AiCorePlugin.kt | 48 ++ .../plugins/aicore/prompt/ApprovalPrompt.kt | 70 +++ .../plugins/aicore/prompt/BackendPrompts.kt | 75 +++ .../aicore/prompt/ContextFilesPrompt.kt | 89 ++++ .../plugins/aicore/prompt/IdeContext.kt | 44 ++ .../plugins/aicore/prompt/IdeContextReader.kt | 65 +++ .../plugins/aicore/prompt/IdeContextSource.kt | 17 + .../{viewmodel => prompt}/ProjectLayout.kt | 2 +- .../aicore/prompt/PromptConfigChecks.kt | 27 + .../aicore/prompt/PromptToolCatalog.kt | 48 ++ .../plugins/aicore/prompt/PromptVariables.kt | 243 +++++++++ .../plugins/aicore/prompt/SessionContext.kt | 35 ++ .../aicore/prompt/SystemPromptFactory.kt | 57 ++ .../aicore/prompt/SystemPromptRenderer.kt | 91 ++++ .../plugins/aicore/prompt/ToolDescriptions.kt | 141 +++++ .../aicore/prompt/ToolResultsPrompt.kt | 172 +++++++ .../aicore/prompt/config/AgentPromptConfig.kt | 172 +++++++ .../prompt/config/AgentPromptConfigParser.kt | 111 ++++ .../prompt/config/SharedPromptConfig.kt | 8 + .../plugins/aicore/tool/AgentLoop.kt | 121 +++-- .../aicore/tool/ToolApprovalManager.kt | 38 +- .../plugins/aicore/tool/ToolCallExtractor.kt | 47 +- .../plugins/aicore/tool/ToolHandler.kt | 4 +- .../aicore/tool/ToolResultsFormatter.kt | 14 + .../plugins/aicore/tool/ToolSchema.kt | 19 +- .../tool/handlers/AddDependencyHandler.kt | 19 +- .../tool/handlers/BuiltInToolHandlers.kt | 12 +- .../aicore/tool/handlers/CreateFileHandler.kt | 5 +- .../aicore/tool/handlers/EditFileHandler.kt | 17 +- .../aicore/tool/handlers/FetchUrlHandler.kt | 160 ++++++ .../handlers/GenerateFromTemplateHandler.kt | 7 +- .../aicore/tool/handlers/GradleSyncHandler.kt | 1 - .../aicore/tool/handlers/ListFilesHandler.kt | 5 +- .../aicore/tool/handlers/OpenFileHandler.kt | 3 +- .../tool/handlers/ReadBuildOutputHandler.kt | 1 - .../aicore/tool/handlers/ReadFileHandler.kt | 3 +- .../aicore/tool/handlers/RunAppHandler.kt | 6 - .../tool/handlers/SearchProjectHandler.kt | 11 +- .../aicore/tool/handlers/UpdateFileHandler.kt | 5 +- .../aicore/tool/handlers/WebSearchHandler.kt | 42 ++ .../aicore/tool/web/BackendWebSearch.kt | 93 ++++ .../aicore/tool/web/VerificationPolicy.kt | 88 ++++ .../plugins/aicore/tool/web/WebAccess.kt | 28 + .../plugins/aicore/tool/web/WebPageText.kt | 103 ++++ .../plugins/aicore/viewmodel/AgentActivity.kt | 32 ++ .../aicore/viewmodel/AgentRunReporter.kt | 9 + .../plugins/aicore/viewmodel/AnswerReview.kt | 103 ++++ .../plugins/aicore/viewmodel/ChatTitle.kt | 52 +- .../plugins/aicore/viewmodel/ChatViewModel.kt | 487 ++++++++---------- .../main/res/layout/fragment_ai_settings.xml | 9 + .../AI-Core/src/main/res/values/strings.xml | 2 + .../aicore/models/ChatTranscriptTest.kt | 41 +- .../aicore/prompt/ApprovalPromptTest.kt | 65 +++ .../aicore/prompt/ContextFilesPromptTest.kt | 94 ++++ .../aicore/prompt/DefaultSystemPromptTest.kt | 148 ++++++ .../aicore/prompt/IdeContextBlockTest.kt | 112 ++++ .../plugins/aicore/prompt/IdeContextTest.kt | 50 ++ .../ProjectLayoutTest.kt | 2 +- .../aicore/prompt/PromptConfigChecksTest.kt | 75 +++ .../aicore/prompt/PromptToolCatalogTest.kt | 58 +++ .../aicore/prompt/SessionContextTest.kt | 26 + .../aicore/prompt/SystemPromptConfigTest.kt | 173 +++++++ .../aicore/prompt/SystemPromptFactoryTest.kt | 139 +++++ .../aicore/prompt/ToolDescriptionsTest.kt | 188 +++++++ .../aicore/prompt/ToolResultsPromptTest.kt | 164 ++++++ .../config/AgentPromptConfigParserTest.kt | 111 ++++ .../config/DirectoryPromptConfigSource.kt | 46 ++ .../prompt/config/ShippedPromptFilesTest.kt | 55 ++ .../plugins/aicore/tool/AgentLoopTest.kt | 328 ++++++++++-- .../plugins/aicore/tool/ExecutorTest.kt | 17 +- .../aicore/tool/ToolApprovalManagerTest.kt | 21 +- .../aicore/tool/ToolCallExtractorTest.kt | 101 ++++ .../tool/handlers/AddDependencyHandlerTest.kt | 72 +++ .../handlers/BuiltInHandlerApprovalTest.kt | 22 +- .../tool/handlers/FetchUrlHandlerTest.kt | 52 ++ .../aicore/tool/handlers/RunAppHandlerTest.kt | 6 +- .../tool/sources/ToolSourceStoreTest.kt | 3 +- .../aicore/tool/web/BackendWebSearchTest.kt | 96 ++++ .../aicore/tool/web/VerificationPolicyTest.kt | 110 ++++ .../aicore/tool/web/WebPageTextTest.kt | 76 +++ .../aicore/viewmodel/AgentActivityTest.kt | 40 ++ .../aicore/viewmodel/AnswerReviewTest.kt | 76 +++ .../plugins/aicore/viewmodel/ChatTitleTest.kt | 37 +- .../viewmodel/ChatViewModelTitleQueueTest.kt | 12 + 195 files changed, 9819 insertions(+), 1363 deletions(-) create mode 100644 plugins/AI-Agent-Gemini/src/main/assets/prompts/agent.yml create mode 100644 plugins/AI-Agent-Gemini/src/main/assets/prompts/layout.yml create mode 100644 plugins/AI-Agent-Gemini/src/main/assets/prompts/rules.yml create mode 100644 plugins/AI-Agent-Gemini/src/main/assets/prompts/scope.yml create mode 100644 plugins/AI-Agent-Gemini/src/main/assets/prompts/tools.yml create mode 100644 plugins/AI-Agent-Gemini/src/main/assets/prompts/workflow.yml create mode 100644 plugins/AI-Agent-Gemini/src/main/kotlin/com/itsaky/androidide/plugins/aiagentgemini/backend/GeminiWebSearch.kt create mode 100644 plugins/AI-Agent-Gemini/src/main/kotlin/com/itsaky/androidide/plugins/aiagentgemini/backend/GroundingRedirect.kt create mode 100644 plugins/AI-Agent-Gemini/src/main/kotlin/com/itsaky/androidide/plugins/aiagentgemini/prompt/GeminiPromptVariables.kt create mode 100644 plugins/AI-Agent-Gemini/src/main/kotlin/com/itsaky/androidide/plugins/aiagentgemini/prompt/config/GeminiPromptConfig.kt create mode 100644 plugins/AI-Agent-Gemini/src/main/kotlin/com/itsaky/androidide/plugins/aiagentgemini/prompt/config/GeminiPromptConfigParser.kt create mode 100644 plugins/AI-Agent-Gemini/src/main/kotlin/com/itsaky/androidide/plugins/aiagentgemini/prompt/config/SharedPromptConfig.kt delete mode 100644 plugins/AI-Agent-Gemini/src/main/kotlin/com/itsaky/androidide/plugins/aiagentgemini/ui/PaneStyling.kt delete mode 100644 plugins/AI-Agent-Gemini/src/main/kotlin/com/itsaky/androidide/plugins/aiagentgemini/ui/SecretRevealController.kt create mode 100644 plugins/AI-Agent-Gemini/src/test/kotlin/com/itsaky/androidide/plugins/aiagentgemini/backend/GeminiWebSearchTest.kt create mode 100644 plugins/AI-Agent-Gemini/src/test/kotlin/com/itsaky/androidide/plugins/aiagentgemini/backend/GroundingRedirectTest.kt create mode 100644 plugins/AI-Agent-Gemini/src/test/kotlin/com/itsaky/androidide/plugins/aiagentgemini/prompt/config/DirectoryPromptConfigSource.kt create mode 100644 plugins/AI-Agent-Gemini/src/test/kotlin/com/itsaky/androidide/plugins/aiagentgemini/prompt/config/GeminiPromptConfigParserTest.kt create mode 100644 plugins/AI-Agent-Gemini/src/test/kotlin/com/itsaky/androidide/plugins/aiagentgemini/prompt/config/ShippedPromptFilesTest.kt create mode 100644 plugins/AI-Agent-Local/src/main/assets/prompts/agent.yml create mode 100644 plugins/AI-Agent-Local/src/main/assets/prompts/layout.yml create mode 100644 plugins/AI-Agent-Local/src/main/assets/prompts/rules.yml create mode 100644 plugins/AI-Agent-Local/src/main/assets/prompts/tools.yml create mode 100644 plugins/AI-Agent-Local/src/main/kotlin/com/itsaky/androidide/plugins/aiagentlocal/prompt/LocalPromptVariables.kt create mode 100644 plugins/AI-Agent-Local/src/main/kotlin/com/itsaky/androidide/plugins/aiagentlocal/prompt/config/LocalPromptConfig.kt create mode 100644 plugins/AI-Agent-Local/src/main/kotlin/com/itsaky/androidide/plugins/aiagentlocal/prompt/config/LocalPromptConfigParser.kt create mode 100644 plugins/AI-Agent-Local/src/main/kotlin/com/itsaky/androidide/plugins/aiagentlocal/prompt/config/SharedPromptConfig.kt create mode 100644 plugins/AI-Agent-Local/src/test/kotlin/com/itsaky/androidide/plugins/aiagentlocal/prompt/LocalSystemPromptTest.kt create mode 100644 plugins/AI-Agent-Local/src/test/kotlin/com/itsaky/androidide/plugins/aiagentlocal/prompt/config/DirectoryPromptConfigSource.kt create mode 100644 plugins/AI-Agent-Local/src/test/kotlin/com/itsaky/androidide/plugins/aiagentlocal/prompt/config/LocalPromptConfigParserTest.kt create mode 100644 plugins/AI-Agent-Local/src/test/kotlin/com/itsaky/androidide/plugins/aiagentlocal/prompt/config/ShippedPromptFilesTest.kt delete mode 100644 plugins/AI-Agent-MCP/src/main/kotlin/com/itsaky/androidide/plugins/aiagentmcp/ui/SecretRevealController.kt create mode 100644 plugins/AI-Agent-OpenAI/src/main/assets/prompts/agent.yml create mode 100644 plugins/AI-Agent-OpenAI/src/main/assets/prompts/layout.yml create mode 100644 plugins/AI-Agent-OpenAI/src/main/assets/prompts/rules.yml create mode 100644 plugins/AI-Agent-OpenAI/src/main/assets/prompts/scope.yml create mode 100644 plugins/AI-Agent-OpenAI/src/main/assets/prompts/tools.yml create mode 100644 plugins/AI-Agent-OpenAI/src/main/assets/prompts/workflow.yml create mode 100644 plugins/AI-Agent-OpenAI/src/main/kotlin/com/itsaky/androidide/plugins/aiagentopenai/backend/OpenAiWebSearch.kt create mode 100644 plugins/AI-Agent-OpenAI/src/main/kotlin/com/itsaky/androidide/plugins/aiagentopenai/errors/OpenAiReplyException.kt create mode 100644 plugins/AI-Agent-OpenAI/src/main/kotlin/com/itsaky/androidide/plugins/aiagentopenai/errors/OpenAiTimeoutException.kt create mode 100644 plugins/AI-Agent-OpenAI/src/main/kotlin/com/itsaky/androidide/plugins/aiagentopenai/prompt/OpenAiPromptVariables.kt create mode 100644 plugins/AI-Agent-OpenAI/src/main/kotlin/com/itsaky/androidide/plugins/aiagentopenai/prompt/config/OpenAiPromptConfig.kt create mode 100644 plugins/AI-Agent-OpenAI/src/main/kotlin/com/itsaky/androidide/plugins/aiagentopenai/prompt/config/OpenAiPromptConfigParser.kt create mode 100644 plugins/AI-Agent-OpenAI/src/main/kotlin/com/itsaky/androidide/plugins/aiagentopenai/prompt/config/SharedPromptConfig.kt delete mode 100644 plugins/AI-Agent-OpenAI/src/main/kotlin/com/itsaky/androidide/plugins/aiagentopenai/ui/PaneStyling.kt delete mode 100644 plugins/AI-Agent-OpenAI/src/main/kotlin/com/itsaky/androidide/plugins/aiagentopenai/ui/SecretRevealController.kt create mode 100644 plugins/AI-Agent-OpenAI/src/test/kotlin/com/itsaky/androidide/plugins/aiagentopenai/backend/OpenAiWebSearchTest.kt create mode 100644 plugins/AI-Agent-OpenAI/src/test/kotlin/com/itsaky/androidide/plugins/aiagentopenai/prompt/config/DirectoryPromptConfigSource.kt create mode 100644 plugins/AI-Agent-OpenAI/src/test/kotlin/com/itsaky/androidide/plugins/aiagentopenai/prompt/config/OpenAiPromptConfigParserTest.kt create mode 100644 plugins/AI-Agent-OpenAI/src/test/kotlin/com/itsaky/androidide/plugins/aiagentopenai/prompt/config/ShippedPromptFilesTest.kt create mode 100644 plugins/AI-Core/src/main/assets/prompts/agent.yml create mode 100644 plugins/AI-Core/src/main/assets/prompts/agent_loop.yml create mode 100644 plugins/AI-Core/src/main/assets/prompts/answer_review.yml create mode 100644 plugins/AI-Core/src/main/assets/prompts/chat_title.yml create mode 100644 plugins/AI-Core/src/main/assets/prompts/context_files.yml create mode 100644 plugins/AI-Core/src/main/assets/prompts/ide_context.yml create mode 100644 plugins/AI-Core/src/main/assets/prompts/layout.yml create mode 100644 plugins/AI-Core/src/main/assets/prompts/rules.yml create mode 100644 plugins/AI-Core/src/main/assets/prompts/tool_descriptions.yml create mode 100644 plugins/AI-Core/src/main/assets/prompts/tools.yml create mode 100644 plugins/AI-Core/src/main/assets/prompts/web_search.yml create mode 100644 plugins/AI-Core/src/main/kotlin/com/itsaky/androidide/plugins/aicore/prompt/ApprovalPrompt.kt create mode 100644 plugins/AI-Core/src/main/kotlin/com/itsaky/androidide/plugins/aicore/prompt/BackendPrompts.kt create mode 100644 plugins/AI-Core/src/main/kotlin/com/itsaky/androidide/plugins/aicore/prompt/ContextFilesPrompt.kt create mode 100644 plugins/AI-Core/src/main/kotlin/com/itsaky/androidide/plugins/aicore/prompt/IdeContext.kt create mode 100644 plugins/AI-Core/src/main/kotlin/com/itsaky/androidide/plugins/aicore/prompt/IdeContextReader.kt create mode 100644 plugins/AI-Core/src/main/kotlin/com/itsaky/androidide/plugins/aicore/prompt/IdeContextSource.kt rename plugins/AI-Core/src/main/kotlin/com/itsaky/androidide/plugins/aicore/{viewmodel => prompt}/ProjectLayout.kt (98%) create mode 100644 plugins/AI-Core/src/main/kotlin/com/itsaky/androidide/plugins/aicore/prompt/PromptConfigChecks.kt create mode 100644 plugins/AI-Core/src/main/kotlin/com/itsaky/androidide/plugins/aicore/prompt/PromptToolCatalog.kt create mode 100644 plugins/AI-Core/src/main/kotlin/com/itsaky/androidide/plugins/aicore/prompt/PromptVariables.kt create mode 100644 plugins/AI-Core/src/main/kotlin/com/itsaky/androidide/plugins/aicore/prompt/SessionContext.kt create mode 100644 plugins/AI-Core/src/main/kotlin/com/itsaky/androidide/plugins/aicore/prompt/SystemPromptFactory.kt create mode 100644 plugins/AI-Core/src/main/kotlin/com/itsaky/androidide/plugins/aicore/prompt/SystemPromptRenderer.kt create mode 100644 plugins/AI-Core/src/main/kotlin/com/itsaky/androidide/plugins/aicore/prompt/ToolDescriptions.kt create mode 100644 plugins/AI-Core/src/main/kotlin/com/itsaky/androidide/plugins/aicore/prompt/ToolResultsPrompt.kt create mode 100644 plugins/AI-Core/src/main/kotlin/com/itsaky/androidide/plugins/aicore/prompt/config/AgentPromptConfig.kt create mode 100644 plugins/AI-Core/src/main/kotlin/com/itsaky/androidide/plugins/aicore/prompt/config/AgentPromptConfigParser.kt create mode 100644 plugins/AI-Core/src/main/kotlin/com/itsaky/androidide/plugins/aicore/prompt/config/SharedPromptConfig.kt create mode 100644 plugins/AI-Core/src/main/kotlin/com/itsaky/androidide/plugins/aicore/tool/ToolResultsFormatter.kt create mode 100644 plugins/AI-Core/src/main/kotlin/com/itsaky/androidide/plugins/aicore/tool/handlers/FetchUrlHandler.kt create mode 100644 plugins/AI-Core/src/main/kotlin/com/itsaky/androidide/plugins/aicore/tool/handlers/WebSearchHandler.kt create mode 100644 plugins/AI-Core/src/main/kotlin/com/itsaky/androidide/plugins/aicore/tool/web/BackendWebSearch.kt create mode 100644 plugins/AI-Core/src/main/kotlin/com/itsaky/androidide/plugins/aicore/tool/web/VerificationPolicy.kt create mode 100644 plugins/AI-Core/src/main/kotlin/com/itsaky/androidide/plugins/aicore/tool/web/WebAccess.kt create mode 100644 plugins/AI-Core/src/main/kotlin/com/itsaky/androidide/plugins/aicore/tool/web/WebPageText.kt create mode 100644 plugins/AI-Core/src/main/kotlin/com/itsaky/androidide/plugins/aicore/viewmodel/AnswerReview.kt create mode 100644 plugins/AI-Core/src/test/kotlin/com/itsaky/androidide/plugins/aicore/prompt/ApprovalPromptTest.kt create mode 100644 plugins/AI-Core/src/test/kotlin/com/itsaky/androidide/plugins/aicore/prompt/ContextFilesPromptTest.kt create mode 100644 plugins/AI-Core/src/test/kotlin/com/itsaky/androidide/plugins/aicore/prompt/DefaultSystemPromptTest.kt create mode 100644 plugins/AI-Core/src/test/kotlin/com/itsaky/androidide/plugins/aicore/prompt/IdeContextBlockTest.kt create mode 100644 plugins/AI-Core/src/test/kotlin/com/itsaky/androidide/plugins/aicore/prompt/IdeContextTest.kt rename plugins/AI-Core/src/test/kotlin/com/itsaky/androidide/plugins/aicore/{viewmodel => prompt}/ProjectLayoutTest.kt (98%) create mode 100644 plugins/AI-Core/src/test/kotlin/com/itsaky/androidide/plugins/aicore/prompt/PromptConfigChecksTest.kt create mode 100644 plugins/AI-Core/src/test/kotlin/com/itsaky/androidide/plugins/aicore/prompt/PromptToolCatalogTest.kt create mode 100644 plugins/AI-Core/src/test/kotlin/com/itsaky/androidide/plugins/aicore/prompt/SessionContextTest.kt create mode 100644 plugins/AI-Core/src/test/kotlin/com/itsaky/androidide/plugins/aicore/prompt/SystemPromptConfigTest.kt create mode 100644 plugins/AI-Core/src/test/kotlin/com/itsaky/androidide/plugins/aicore/prompt/SystemPromptFactoryTest.kt create mode 100644 plugins/AI-Core/src/test/kotlin/com/itsaky/androidide/plugins/aicore/prompt/ToolDescriptionsTest.kt create mode 100644 plugins/AI-Core/src/test/kotlin/com/itsaky/androidide/plugins/aicore/prompt/ToolResultsPromptTest.kt create mode 100644 plugins/AI-Core/src/test/kotlin/com/itsaky/androidide/plugins/aicore/prompt/config/AgentPromptConfigParserTest.kt create mode 100644 plugins/AI-Core/src/test/kotlin/com/itsaky/androidide/plugins/aicore/prompt/config/DirectoryPromptConfigSource.kt create mode 100644 plugins/AI-Core/src/test/kotlin/com/itsaky/androidide/plugins/aicore/prompt/config/ShippedPromptFilesTest.kt create mode 100644 plugins/AI-Core/src/test/kotlin/com/itsaky/androidide/plugins/aicore/tool/handlers/AddDependencyHandlerTest.kt create mode 100644 plugins/AI-Core/src/test/kotlin/com/itsaky/androidide/plugins/aicore/tool/handlers/FetchUrlHandlerTest.kt create mode 100644 plugins/AI-Core/src/test/kotlin/com/itsaky/androidide/plugins/aicore/tool/web/BackendWebSearchTest.kt create mode 100644 plugins/AI-Core/src/test/kotlin/com/itsaky/androidide/plugins/aicore/tool/web/VerificationPolicyTest.kt create mode 100644 plugins/AI-Core/src/test/kotlin/com/itsaky/androidide/plugins/aicore/tool/web/WebPageTextTest.kt create mode 100644 plugins/AI-Core/src/test/kotlin/com/itsaky/androidide/plugins/aicore/viewmodel/AnswerReviewTest.kt diff --git a/plugins/AI-Agent-Gemini/README.md b/plugins/AI-Agent-Gemini/README.md index 30b50d3f..bbb4bbc8 100644 --- a/plugins/AI-Agent-Gemini/README.md +++ b/plugins/AI-Agent-Gemini/README.md @@ -53,6 +53,48 @@ message shape thrown by `fetchAvailableModels`, are a contract — the pane is mounted by ai-core across the plugin classloader boundary, so `proguard-rules.pro` pins the class and its public methods. +## System prompt config + +The prompt Gemini asks ai-core to send lives in `src/main/assets/prompts/`, one YAML +file per concern, apart from the code that sends it. Changing the tone, adding a +rule or translating the prompt is an edit to those files alone. ai-core appends its +own IDE CONTEXT block after the rendered prompt. + +The files are loaded, validated and cached once, when the plugin is activated. +`getSystemPrompt` renders `layout.yml` from that cache for each request, since the +tool list, the protocol and the example path vary per run; it never waits. Until the +config has loaded, or if it cannot render, it returns null and ai-core sends its own +default prompt. + +| File | Keys | What it is | +|---|---|---| +| `agent.yml` | `schema_version`, `identity`, `include` | The entry point: the version (`1`; another is refused rather than misread), who the agent is, and the files below. | +| `scope.yml` | `scope` | What the agent will answer: anything, with the project's tools only when the request is about the open project. | +| `rules.yml` | `rules` | Priority groups, highest first; each has a `heading` (`CRITICAL`, `IMPORTANT`, `MANDATORY`, `OPTIONAL`) and its `items`. **Adding a rule is adding an item.** | +| `workflow.yml` | `behavior`, `workflow` | How to go about building or changing something; the workflow's `steps` are numbered when rendered. | +| `tools.yml` | `tools`, `tool_call_format` | What introduces the tool list, and how to call a tool: `native` under the function-calling API, `text` (with its examples) when calls travel in the reply. Exactly one is sent. | +| `layout.yml` | `layout.system_prompt` | Where each text goes. | + +Loading and checking follow ai-core's rules (see ai-core's README): a key belongs to +one file, only `agent.yml` includes, and a missing, unknown, misspelled or duplicate +key, an empty list or an unquoted number is refused naming the file and path, e.g. +`rules.yml: rules[1].items is empty`. Texts are named by their YAML path in upper +case (`scope.heading` is `SCOPE_HEADING`); each rule group has `HEADING` and `ITEMS`, +each item and step has `TEXT`, each step has `NUMBER`, and each example has `PURPOSE` +and `CALL`. The request's values are `TOOLS` (each with `NAME`, `DESCRIPTION`, +inserted verbatim), `TOOL_CALL_SYNTAX` (null under native calling), +`NATIVE_TOOL_CALLS`, `EXAMPLE_FILE_PATH` and `EXAMPLE_FILE_STEM`. + +Rendering is strict: an unknown name throws, naming the text it was in. Activation +renders the prompt for requests that open and close every section and logs any +failure, and `GeminiSystemPromptTest` fails on one in the shipped files. A new key +needs `GeminiPromptConfig` and its parser; a new name needs `GeminiPromptVariables`. + +The engine and the YAML plumbing (`PromptTemplateEngine`, `PromptConfigLoader`, +`PromptConfigStore`, `PromptConfigObject`, ...) are the IDE's, in `plugin-api.jar`'s +`com.itsaky.androidide.plugins.ai.prompt`, shared with ai-core and the other backends. +Only `GeminiPromptConfig`, its mapping in `GeminiPromptConfigParser`, and `sharedPromptConfig` are this plugin's own. + ## Key classes Every source file sits in a package named for its layer; nothing is loose at the @@ -65,7 +107,9 @@ root of `com/itsaky/androidide/plugins/aiagentgemini/`. - `security/SecureApiKeyStore.kt` — this plugin's Keystore alias, over the IDE's `KeystoreSecretStore` - `preferences/GeminiPreferences.kt` — this plugin's settings store, plus the one-time adoption of settings written under earlier plugin ids -- `prompt/GeminiSystemPrompt.kt` — the system prompt this cloud model is given +- `prompt/GeminiSystemPrompt.kt` — renders `layout.yml` from `GeminiPromptVariables`; + `prompt/config/` maps `assets/prompts/` onto this plugin's config type, which the + IDE's `ai.prompt` package loads, validates, caches and renders - `logging/` — `LOG_PREFIX` (`AiAgentGemini`), prefixing every logcat tag this plugin writes - `settings/` — the settings pane this backend contributes to the selector diff --git a/plugins/AI-Agent-Gemini/build.gradle.kts b/plugins/AI-Agent-Gemini/build.gradle.kts index 71aeadaf..1c097631 100644 --- a/plugins/AI-Agent-Gemini/build.gradle.kts +++ b/plugins/AI-Agent-Gemini/build.gradle.kts @@ -64,7 +64,10 @@ dependencies { implementation("org.jetbrains.kotlin:kotlin-stdlib:2.3.21") implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.7.3") + testImplementation(files("../../libs/plugin-api.jar")) + // plugin-api's prompt loader parses YAML with the host's copy; JVM tests need their own, same version + testImplementation("org.snakeyaml:snakeyaml-engine:2.10") testImplementation("junit:junit:4.13.2") testImplementation("io.mockk:mockk:1.13.8") testImplementation("org.json:json:20231013") @@ -78,3 +81,8 @@ tasks.matching { it.name.contains("checkDebugAarMetadata") || it.name.contains("checkReleaseAarMetadata") }.configureEach { enabled = false } + +// The prompt tests read src/main/assets/prompts from disk; declared, so a YAML-only edit reruns them. +tasks.withType().configureEach { + inputs.dir("src/main/assets/prompts").withPropertyName("shippedPrompts") +} diff --git a/plugins/AI-Agent-Gemini/src/main/assets/prompts/agent.yml b/plugins/AI-Agent-Gemini/src/main/assets/prompts/agent.yml new file mode 100644 index 00000000..82e85b62 --- /dev/null +++ b/plugins/AI-Agent-Gemini/src/main/assets/prompts/agent.yml @@ -0,0 +1,23 @@ +# Gemini's system prompt: the wording this backend asks ai-core to send, apart from the code that +# sends it. Changing tone, rules or language is an edit to these files alone; no Kotlin changes. +# +# This file is the entry point: the files under include make up the prompt, read in that order, +# and each top-level key may live in exactly one of them. Every text is a template over the +# request's values, e.g. {{EXAMPLE_FILE_PATH}}; see README.md. Gemini validates them on +# activation, and GeminiSystemPromptTest fails on a mistake in the shipped files. ai-core appends +# its own IDE CONTEXT block after the rendered prompt. + +schema_version: 1 + +# Who the agent is; the first thing the model reads. +identity: >- + You are the coding assistant built into CodeOnTheGo, an Android IDE that runs on the user's + phone or tablet. Most requests you get are about the Android project that is open, and you have + tools for it — but you are a general assistant first. + +include: + - scope.yml + - rules.yml + - workflow.yml + - tools.yml + - layout.yml diff --git a/plugins/AI-Agent-Gemini/src/main/assets/prompts/layout.yml b/plugins/AI-Agent-Gemini/src/main/assets/prompts/layout.yml new file mode 100644 index 00000000..7db7aeda --- /dev/null +++ b/plugins/AI-Agent-Gemini/src/main/assets/prompts/layout.yml @@ -0,0 +1,55 @@ +# Where each text from the other files goes, by the name it is rendered under (see README.md). +# A line holding only a section tag (#, ^ or /) vanishes, so tags can sit on their own lines. + +layout: + system_prompt: |- + {{IDENTITY}} + + {{SCOPE_HEADING}}: + {{#SCOPE_ITEMS}} + - {{TEXT}} + {{/SCOPE_ITEMS}} + + {{TOOLS_HEADING}}: + {{#TOOLS}} + - {{NAME}}: {{DESCRIPTION}} + {{/TOOLS}} + + {{BEHAVIOR_HEADING}}: + {{#BEHAVIOR_ITEMS}} + - {{TEXT}} + {{/BEHAVIOR_ITEMS}} + + {{#RULES}} + {{^FIRST}} + + {{/FIRST}} + {{HEADING}}: + {{#ITEMS}} + - {{TEXT}} + {{/ITEMS}} + {{/RULES}} + {{#NATIVE_TOOL_CALLS}} + + {{TOOL_CALL_FORMAT_NATIVE}} + {{TOOL_CALL_FORMAT_NO_NARRATION}} + {{/NATIVE_TOOL_CALLS}} + {{#TOOL_CALL_SYNTAX}} + + {{TOOL_CALL_FORMAT_TEXT_INSTRUCTION}} + {{TOOL_CALL_SYNTAX}} + {{TOOL_CALL_FORMAT_NO_NARRATION}} + {{TOOL_CALL_FORMAT_TEXT_ONLY_THE_LINE_RUNS}} + + {{TOOL_CALL_FORMAT_TEXT_EXAMPLES_HEADING}}: + {{#TOOL_CALL_FORMAT_TEXT_EXAMPLES}} + {{PURPOSE}}: + {{CALL}} + {{/TOOL_CALL_FORMAT_TEXT_EXAMPLES}} + {{/TOOL_CALL_SYNTAX}} + + {{WORKFLOW_HEADING}}: + {{#WORKFLOW_STEPS}} + {{NUMBER}}. {{TEXT}} + {{/WORKFLOW_STEPS}} + {{WORKFLOW_CLOSING}} diff --git a/plugins/AI-Agent-Gemini/src/main/assets/prompts/rules.yml b/plugins/AI-Agent-Gemini/src/main/assets/prompts/rules.yml new file mode 100644 index 00000000..d26139f0 --- /dev/null +++ b/plugins/AI-Agent-Gemini/src/main/assets/prompts/rules.yml @@ -0,0 +1,48 @@ +# What the agent must and must not do, highest priority first. Adding a rule is adding an item. +# Each group renders as "HEADING:" with its items as "- " lines. + +rules: + - heading: CRITICAL + items: + - >- + When you call a tool, emit ONE per reply, then stop and wait. Do NOT plan a batch: a tool + whose arguments depend on another tool's result (editing a file you just searched for) + cannot use a result you have not received yet. + - >- + Never fabricate tool output. Emit a tool call, then wait for the real result before + continuing. + - >- + Never write "User:", "Assistant:", a block, or a ```tool_response fence — + the system supplies real results. Any tool output you write yourself is a hallucination + and will be ignored. + - heading: IMPORTANT + items: + - >- + To locate a file, call search_project ONCE with its name — it searches the whole project. + Never walk the tree with repeated list_files calls; you have a limited number of turns and + each level wastes one. + - >- + Renaming a symbol everywhere in a file is ONE edit_file with replace_all set to true and + old_string set to just the symbol — not one edit per line. + - >- + To change an existing file, use edit_file (find/replace an exact snippet), not update_file + — a whole-file rewrite gets truncated before it reaches disk. + - >- + Before edit_file, read the exact file you are about to edit with read_file, and copy + old_string byte-for-byte from that output, including indentation. Never edit a path you + have not confirmed exists. + - heading: MANDATORY + items: + - >- + old_string must be the text currently in the file and new_string what it should become. If + they are identical the edit is rejected. + - >- + 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. + - >- + A greeting, or a question you can answer without reading the project or checking a claim + on the web, is answered in the reply itself, with no tool call — briefly for small talk, in + full for a real question. Once you have called any tool, the task ends only with a single respond call + carrying your summary in its "message" — never an empty respond. A reply without a tool + call does not finish it. diff --git a/plugins/AI-Agent-Gemini/src/main/assets/prompts/scope.yml b/plugins/AI-Agent-Gemini/src/main/assets/prompts/scope.yml new file mode 100644 index 00000000..838da7f0 --- /dev/null +++ b/plugins/AI-Agent-Gemini/src/main/assets/prompts/scope.yml @@ -0,0 +1,63 @@ +# What the agent will answer, and how it makes sure the answer is right. Each item renders as a +# "- " line under "HEADING:". Principles only: an example named here gets pattern-matched rather +# than understood, and the next defect is always a different one (ADFA-6223). + +scope: + heading: SCOPE + items: + - >- + Answer whatever the user asks. A question about another language, another platform, a + general programming concept, or something that is not about code at all is an ordinary + request: answer it directly and well. + - >- + Never decline a request on the grounds that it is not about Android, not about this + project, or not about code. You have no such restriction. + - >- + Reach for a project tool only when the request is about the open project's files. + # Confidence is the model's signal for searching, and it is highest exactly where the world + # has moved on since training; so the trigger is the kind of claim, not how sure it feels. + - >- + Your knowledge stops at a cutoff, and today's date is stated below. A claim that can stop + being true over time — whether a library, API or tool is current, deprecated or removed, + what replaced it, its latest version, the recommended way to use it — must be checked + before you make it, whenever web_search is among your tools. Judging code is such a claim: + calling code correct, current or good practice asserts that everything it uses still is. + Feeling sure is not checking. Search each claim on its own, naming exactly what you are + checking. If the results leave it open, search more precisely or read the primary source + with fetch_url; if it is still open, say what you could not verify. + - >- + The user never sees tool results, only your replies. State every fact you took from a + search or a page in the reply itself, with the link it came from next to it. + - >- + A request to review, analyze or examine code asks what is wrong with it. Check the code as + given before anything else: whether it compiles as written, whether what it uses is current, + and whether every path through it does what its author meant. Lead with the findings, each + with its evidence, before anything the code does well. + - >- + When you propose changed code, every difference from the original is a finding: state what + you changed and why, including an added import, annotation, opt-in or dependency. Your + version fixes every finding and never carries forward anything you found to be wrong. + # The self-check. Each item is a way of reasoning about code, not a list of known bugs. + - >- + Before you send code, check it as hard as you checked the user's. Trace every branch and + state to the concrete situations that reach it; if situations that need different behavior + reach the same branch, the code is wrong until you add what tells them apart. + - >- + Every operation in your code must be valid for every value its inputs can hold. Where it is + valid for only some, narrow what the code accepts or handle the rest — never assume. + - >- + Use each API the way its own documentation intends, and prefer what a library or platform + already provides over reimplementing it by hand. + - >- + Never hedge inside code — a fallback control, a comment or label saying "if this applies". + Hedging means a question is still open: resolve it, and if you cannot, say so in prose. + - >- + Code you send is complete: every import, annotation and opt-in it needs is present, and + every dependency version comes from a search result or is marked as unverified. + - >- + When a request has several parts (research, design, code), deliver every part. Do not stop + after one part to announce the next or to ask whether to proceed. + - >- + Say you cannot do something only when you genuinely cannot — you have no tool for it, it + needs information you do not have, or it is something you should not do. Say which, and say + what you can do instead. Never ask the user to do what one of your tools can do. diff --git a/plugins/AI-Agent-Gemini/src/main/assets/prompts/tools.yml b/plugins/AI-Agent-Gemini/src/main/assets/prompts/tools.yml new file mode 100644 index 00000000..5bf87dc5 --- /dev/null +++ b/plugins/AI-Agent-Gemini/src/main/assets/prompts/tools.yml @@ -0,0 +1,36 @@ +# How the tool list is introduced, and how to call a tool. Exactly one of the two formats is sent: +# native under the function-calling API, text when calls travel in the reply. + +tools: + heading: AVAILABLE TOOLS + +tool_call_format: + # Sent under either format, after the format's own instruction. + no_narration: >- + Do NOT describe the action in prose (e.g. "Okay, I'll open the file…") — narrating does + nothing. + # Its line breaks are sent as written. + native: |- + TOOL CALL FORMAT — the tools above are declared to you: call one through the function-calling + API. A call written into your reply text is NOT read by this system and will not run. + text: + instruction: >- + TOOL CALL FORMAT — to run a tool, emit a single line in EXACTLY this format and nothing + after it: + only_the_line_runs: The tool only runs when you emit the tool call line itself. + # Its line breaks are sent as written. + examples_heading: |- + FORMAT EXAMPLES (the tool call is the entire reply; the paths are this project's — reuse a path + only when it is the file you actually mean) + # Each renders as "PURPOSE:" followed by the call on its own line. + examples: + - purpose: Report the finished task (the summary goes in "message") + call: '{"tool":"respond","args":{"message":"Renamed count to itemCount."}}' + - purpose: Open a file once you know its path + call: '{"tool":"open_file","args":{"file_path":"{{EXAMPLE_FILE_PATH}}"}}' + - purpose: Find a file by name + call: '{"tool":"search_project","args":{"query":"{{EXAMPLE_FILE_STEM}}"}}' + - purpose: List the project's top-level files (an empty directory means the project root) + call: '{"tool":"list_files","args":{"directory":""}}' + - purpose: Change part of a file (line breaks inside a value MUST be written as \n) + call: '{"tool":"edit_file","args":{"file_path":"{{EXAMPLE_FILE_PATH}}","old_string":"count = 0","new_string":"count = 1"}}' diff --git a/plugins/AI-Agent-Gemini/src/main/assets/prompts/workflow.yml b/plugins/AI-Agent-Gemini/src/main/assets/prompts/workflow.yml new file mode 100644 index 00000000..a3a9a6e9 --- /dev/null +++ b/plugins/AI-Agent-Gemini/src/main/assets/prompts/workflow.yml @@ -0,0 +1,32 @@ +# How the agent goes about building or changing something in the project; neither applies to a +# question, which is answered directly. + +behavior: + heading: BEHAVIOR — for a request to build or change something in this project + items: + - Create complete, production-ready code + - Call tools proactively to build, test, and verify your work + - Read files to understand project structure before making changes + - After each file modification, verify the build compiles + - Generate apps that actually run and work as described + +# Numbered in order when rendered. +workflow: + heading: >- + WORKFLOW — follow these steps only when the user tells you to build or change something in + this project + steps: + - Understand the user's request + - >- + Locate what you need with ONE search_project call — the IDE CONTEXT block below already + names the source, layout and manifest paths + - Create/modify files with complete implementations + - Add dependencies if needed + - Sync gradle and verify compilation + - Run the app to confirm it works + - Report success and what was built + closing: >- + Skip every one of those steps when the user is asking a question, asking for an explanation, + asking about anything other than the open project, or asking you to design, implement or write + code without telling you to add it to their project or app — answer directly instead, with the + complete code in your reply, and offer to add it to the project. diff --git a/plugins/AI-Agent-Gemini/src/main/kotlin/com/itsaky/androidide/plugins/aiagentgemini/backend/GeminiBackend.kt b/plugins/AI-Agent-Gemini/src/main/kotlin/com/itsaky/androidide/plugins/aiagentgemini/backend/GeminiBackend.kt index e7396227..cd213dfc 100644 --- a/plugins/AI-Agent-Gemini/src/main/kotlin/com/itsaky/androidide/plugins/aiagentgemini/backend/GeminiBackend.kt +++ b/plugins/AI-Agent-Gemini/src/main/kotlin/com/itsaky/androidide/plugins/aiagentgemini/backend/GeminiBackend.kt @@ -13,6 +13,7 @@ import com.itsaky.androidide.plugins.aiagentgemini.errors.isCredentialProblem import com.itsaky.androidide.plugins.aiagentgemini.logging.LOG_PREFIX import com.itsaky.androidide.plugins.aiagentgemini.preferences.GeminiPreferences import com.itsaky.androidide.plugins.aiagentgemini.prompt.GeminiSystemPrompt +import com.itsaky.androidide.plugins.aiagentgemini.prompt.config.GeminiPromptConfig import com.itsaky.androidide.plugins.aiagentgemini.security.secureApiKeyStore import com.itsaky.androidide.plugins.security.KeystoreSecretStore import com.itsaky.androidide.plugins.services.LlmInferenceService.* @@ -25,7 +26,10 @@ import kotlinx.coroutines.CancellationException import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job +import kotlinx.coroutines.async +import kotlinx.coroutines.awaitAll import kotlinx.coroutines.cancel +import kotlinx.coroutines.coroutineScope import kotlinx.coroutines.ensureActive import kotlinx.coroutines.isActive import kotlinx.coroutines.launch @@ -50,9 +54,12 @@ private const val TAG = "$LOG_PREFIX.AgentTrace" * but plugins run in the host IDE's classloader where `okhttp3` resolves to the host's older * 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. + * + * @param promptConfig the loaded prompt config, or null while it loads; must return without blocking */ class GeminiBackend( - private val context: PluginContext + private val context: PluginContext, + private val promptConfig: () -> GeminiPromptConfig?, ) : HistoryCapableBackend, CancellableBackend, ConfigurableBackend, ToolCallingBackend, EmbeddingBackend { @@ -113,6 +120,12 @@ class GeminiBackend( /** `finishReason` for a reply the model's output cap cut short. */ private const val FINISH_REASON_MAX_TOKENS = "MAX_TOKENS" + + /** `finishReason` for a tool call Gemini could not put into valid form. */ + private const val FINISH_REASON_MALFORMED_FUNCTION_CALL = "MALFORMED_FUNCTION_CALL" + + /** `finishReason` of an ordinary end of reply. */ + private const val FINISH_REASON_STOP = "STOP" } /** This plugin's own settings, written by its settings pane and read here at request time. */ @@ -325,11 +338,23 @@ class GeminiBackend( override fun getName(): String = "Gemini API" /** - * Written for a large cloud model; see [GeminiSystemPrompt] for why the wording belongs here - * rather than with the caller. + * Written for a large cloud model; see [GeminiSystemPrompt] for why the wording belongs here. + * Null until the config is loaded or when it cannot render, which ai-core + * answers with its default prompt; never blocks, since the caller's thread is ai-core's. */ - override fun getSystemPrompt(request: SystemPromptRequest): String = - GeminiSystemPrompt.build(request) + override fun getSystemPrompt(request: SystemPromptRequest): String? { + val config = promptConfig() + if (config == null) { + context.logger.warn("GeminiBackend: prompt config not loaded; ai-core default used") + return null + } + return try { + GeminiSystemPrompt.build(request, config) + } catch (e: IllegalArgumentException) { + context.logger.error("GeminiBackend: prompt did not render; ai-core default used", e) + null + } + } /** Room to plan, matching the high-autonomy prompt this backend asks for. */ override fun getDefaultTemperature(): Float = 0.7f @@ -363,8 +388,17 @@ class GeminiBackend( val startTime = System.currentTimeMillis() context.logger.info("GeminiBackend: Generating response for prompt (${prompt.length} chars)") - val contents = JSONArray().put(contentJson("user", buildPrompt(prompt, config))) - val text = requestText(getModelName(), apiKey, buildRequestJson(contents, config)) + val contents = JSONArray().put(contentJson("user", prompt)) + val body = buildRequestJson(contents, config) + val text = if (GeminiWebSearch.isRequested(config)) { + val response = requestJson( + getModelName(), METHOD_GENERATE_CONTENT, apiKey, GeminiWebSearch.declareSearch(body) + ) + val resolved = resolveSources(GeminiWebSearch.sourceUris(response)) + GeminiWebSearch.withSources(extractText(response), response, resolved) + } else { + requestText(getModelName(), apiKey, body) + } if (text.isBlank()) { future.complete(LlmResponse.failure("Empty response from Gemini API")) @@ -392,29 +426,26 @@ class GeminiBackend( config: LlmConfig, callback: StreamCallback ) { - val contents = JSONArray().put(contentJson("user", buildPrompt(prompt, config))) + val contents = JSONArray().put(contentJson("user", prompt)) streamContents(contents, config, emptyList(), callback.asToolCallback()) } /** * Builds the `contents[]` array for a multi-turn request. * - * Gemini has no system role, so the system prompt is carried as a leading user turn the model - * acknowledges — the same shape [generateWithHistory] uses, kept in one place so the two - * transports cannot drift apart. - * * Consecutive same-role turns are merged into one content, because the transcript no longer * always alternates: the agent loop drops an ASSISTANT turn that carried only a native call * and no prose, leaving the user message and the tool results it produced adjacent. * + * The system prompt is NOT one of these turns: [buildRequestJson] sends it as the request's + * `systemInstruction`, where the API privileges it over anything a later turn says. + * * @param history the conversation so far, oldest first * @param prompt the current user turn, appended last - * @param config supplies the optional system prompt */ internal fun buildContents( history: List, - prompt: String, - config: LlmConfig + prompt: String ): JSONArray { val turns = mutableListOf>() // Folds a turn into the previous one when the role repeats, so the roles alternate. @@ -426,10 +457,6 @@ class GeminiBackend( turns.add(role to text) } } - config.systemPrompt?.let { systemPrompt -> - add("user", systemPrompt) - add("model", "Understood.") - } for (msg in history) { val role = when (msg.role) { ChatMessage.Role.USER -> "user" @@ -481,6 +508,7 @@ class GeminiBackend( Log.i( TAG, "REQUEST | model=${getModelName()} turns=${contents.length()} " + + "sys=${body.has("systemInstruction")} " + "tools=${tools.size} declared=${declaredToolCount(body)} " + tools.joinToString(",") { it.name } ) @@ -551,12 +579,21 @@ class GeminiBackend( callback.onError(userMessage(GeminiFailure.ReplyTruncated)) toolCallCount == 0 && finalText.isBlank() -> - callback.onError("Empty response from Gemini API") + callback.onError(userMessage(GeminiFailure.NoReply(finishReason))) else -> { - val tokenCount = finalText.split("\\s+".toRegex()).size + // Prose the cap cut off mid-sentence otherwise reads as a finished answer. + val cutOff = toolCallCount == 0 && finishReason == FINISH_REASON_MAX_TOKENS + val reply = if (cutOff) { + val note = "\n\n" + cutOffNote() + callback.onToken(note) + finalText + note + } else { + finalText + } + val tokenCount = reply.split("\\s+".toRegex()).size callback.onComplete( - LlmResponse.success(finalText, tokenCount, System.currentTimeMillis() - startTime) + LlmResponse.success(reply, tokenCount, System.currentTimeMillis() - startTime) ) } } @@ -590,7 +627,7 @@ class GeminiBackend( val startTime = System.currentTimeMillis() - val contents = buildContents(history, prompt, config) + val contents = buildContents(history, prompt) val text = requestText(getModelName(), apiKey, buildRequestJson(contents, config)) @@ -774,22 +811,12 @@ class GeminiBackend( return ModelCatalog(chat = chat.distinct(), embedding = embedding.distinct()) } - /** - * Build the full prompt including system instructions. - */ - private fun buildPrompt(userPrompt: String, config: LlmConfig): String { - val systemPrompt = config.systemPrompt ?: "You are a helpful coding assistant." - return """$systemPrompt - -User: $userPrompt""" - } - /** * Streams a reply for a multi-turn conversation, sending [history] as real `contents[]` turns. * * @param history the conversation so far, oldest first * @param prompt the current user turn - * @param config sampling settings; its system prompt becomes the leading turn pair + * @param config sampling settings; its system prompt is sent as the request's systemInstruction * @param callback receives tokens, completion, and errors */ override fun generateStreamingWithHistory( @@ -798,7 +825,7 @@ User: $userPrompt""" config: LlmConfig, callback: StreamCallback ) { - streamContents(buildContents(history, prompt, config), config, emptyList(), callback.asToolCallback()) + streamContents(buildContents(history, prompt), config, emptyList(), callback.asToolCallback()) } /** @@ -811,7 +838,7 @@ User: $userPrompt""" * * @param prompt the current user turn * @param history the conversation so far, oldest first - * @param config sampling settings; its system prompt becomes the leading turn pair + * @param config sampling settings; its system prompt is sent as the request's systemInstruction * @param tools the tools to declare; an empty list streams plain text * @param callback receives tokens, tool calls, completion, and errors */ @@ -822,7 +849,7 @@ User: $userPrompt""" tools: List, callback: ToolStreamCallback ) { - streamContents(buildContents(history, prompt, config), config, tools, callback) + streamContents(buildContents(history, prompt), config, tools, callback) } /** Cancel any in-flight generation (user pressed Stop). */ @@ -859,6 +886,20 @@ User: $userPrompt""" private fun requestText(model: String, apiKey: String, body: JSONObject): String = extractText(requestJson(model, METHOD_GENERATE_CONTENT, apiKey, body)) + /** + * Each source link's real target, resolved in parallel; see [GroundingRedirect]. + * + * @param uris the links as the grounding metadata gave them. + * @return each link mapped to its target, which is the link itself when it could not be read. + */ + private suspend fun resolveSources(uris: List): Map = coroutineScope { + uris.map { uri -> + async(Dispatchers.IO) { + uri to withTrafficTag(NetworkTags.SEARCH_SOURCES) { GroundingRedirect.target(uri) } + } + }.awaitAll().toMap() + } + /** * POST [body] to a model method and return the parsed response. * @@ -945,12 +986,16 @@ User: $userPrompt""" /** * Build a generateContent request body. * + * The system prompt goes in `systemInstruction`, not in `contents`: sent as a user turn it + * carried no more weight than text the model later read out of a file (ADFA-6223). + * * @param contents the `contents` array of role/parts turns - * @param config supplies temperature and max output tokens + * @param config supplies the system prompt, temperature, max output tokens and any tool the + * turn must call; see [GeminiToolProtocol.requiredToolConfig] * @param tools the tools to declare; omitted from the body when empty * @return the request JSON */ - private fun buildRequestJson( + internal fun buildRequestJson( contents: JSONArray, config: LlmConfig, tools: List = emptyList(), @@ -963,6 +1008,10 @@ User: $userPrompt""" .put("temperature", config.temperature.toDouble()) .put("maxOutputTokens", config.maxTokens) ) + // systemInstruction takes no role; sending one is accepted but says nothing. + config.systemPrompt?.takeIf { it.isNotBlank() }?.let { systemPrompt -> + body.put("systemInstruction", partsJson(systemPrompt)) + } if (tools.isEmpty()) return body // A schema this side cannot express must not cost the user the whole request: dropping the // declarations degrades to the text envelope the prompt still describes. @@ -970,7 +1019,9 @@ User: $userPrompt""" Log.w(TAG, "REQUEST | could not declare tools, falling back to text calls", it) return body } - return body.put("tools", JSONArray().put(JSONObject().put("functionDeclarations", declarations))) + body.put("tools", JSONArray().put(JSONObject().put("functionDeclarations", declarations))) + GeminiToolProtocol.requiredToolConfig(config, tools)?.let { body.put("toolConfig", it) } + return body } /** @@ -997,9 +1048,17 @@ User: $userPrompt""" * @return a `{role, parts:[{text}]}` object */ private fun contentJson(role: String, text: String): JSONObject = - JSONObject() - .put("role", role) - .put("parts", JSONArray().put(JSONObject().put("text", text))) + partsJson(text).put("role", role) + + /** + * Build a `{parts:[{text}]}` object — a turn without its role, which is what + * `systemInstruction` takes. + * + * @param text the single text part + * @return the parts object + */ + private fun partsJson(text: String): JSONObject = + JSONObject().put("parts", JSONArray().put(JSONObject().put("text", text))) /** * Adapts a plain stream callback to the tool-aware one [streamContents] takes. @@ -1088,6 +1147,13 @@ User: $userPrompt""" GeminiFailure.ReplyTruncated -> resources.getString(R.string.gemini_error_truncated) + is GeminiFailure.NoReply -> when (failure.finishReason) { + FINISH_REASON_MALFORMED_FUNCTION_CALL -> + resources.getString(R.string.gemini_error_malformed_call) + null, FINISH_REASON_STOP -> resources.getString(R.string.gemini_error_empty) + else -> resources.getString(R.string.gemini_error_empty_reason, failure.finishReason) + } + is GeminiFailure.Failed -> failure.reason?.let { resources.getString(R.string.gemini_error_failed_reason, it) } ?: resources.getString(R.string.gemini_error_failed) @@ -1096,6 +1162,14 @@ User: $userPrompt""" context.logger.error("GeminiBackend: could not resolve error string for $failure", e) "The Gemini request failed." } + + /** The line appended to a reply the output cap cut short, from the plugin's own resources. */ + private fun cutOffNote(): String = try { + context.androidContext.getString(R.string.gemini_note_reply_cut_off) + } catch (e: Exception) { + context.logger.error("GeminiBackend: could not resolve the cut-off note", e) + "[Reply cut off at the output limit.]" + } } /** diff --git a/plugins/AI-Agent-Gemini/src/main/kotlin/com/itsaky/androidide/plugins/aiagentgemini/backend/GeminiToolProtocol.kt b/plugins/AI-Agent-Gemini/src/main/kotlin/com/itsaky/androidide/plugins/aiagentgemini/backend/GeminiToolProtocol.kt index 5de42548..ca65c5d9 100644 --- a/plugins/AI-Agent-Gemini/src/main/kotlin/com/itsaky/androidide/plugins/aiagentgemini/backend/GeminiToolProtocol.kt +++ b/plugins/AI-Agent-Gemini/src/main/kotlin/com/itsaky/androidide/plugins/aiagentgemini/backend/GeminiToolProtocol.kt @@ -1,5 +1,6 @@ package com.itsaky.androidide.plugins.aiagentgemini.backend +import com.itsaky.androidide.plugins.services.LlmInferenceService.LlmConfig import com.itsaky.androidide.plugins.services.LlmInferenceService.ToolCallRequest import com.itsaky.androidide.plugins.services.LlmInferenceService.ToolDefinition import org.json.JSONArray @@ -22,6 +23,26 @@ internal object GeminiToolProtocol { /** Appended when an object argument has to be declared as JSON text; see [declarable]. */ private const val AS_JSON_TEXT = " Written as a JSON object." + /** The key ai-core's `WebAccess.EXTRA_PARAM_REQUIRED_TOOL` sets; the same literal on both sides. */ + const val EXTRA_PARAM_REQUIRED_TOOL = "required_tool" + + /** + * The `toolConfig` that makes the model call [config]'s required tool this turn. + * + * @param config the turn's config; its `required_tool` extra names the tool. + * @param tools the tools the request declares. + * @return the config, or null when none is required or the required one is not declared, which + * Gemini would refuse the whole request over. + */ + fun requiredToolConfig(config: LlmConfig, tools: List): JSONObject? { + val name = config.extraParams?.get(EXTRA_PARAM_REQUIRED_TOOL) as? String ?: return null + if (tools.none { it.name == name }) return null + val calling = JSONObject() + .put("mode", "ANY") + .put("allowedFunctionNames", JSONArray().put(name)) + return JSONObject().put("functionCallingConfig", calling) + } + /** * One parsed stream chunk. * diff --git a/plugins/AI-Agent-Gemini/src/main/kotlin/com/itsaky/androidide/plugins/aiagentgemini/backend/GeminiWebSearch.kt b/plugins/AI-Agent-Gemini/src/main/kotlin/com/itsaky/androidide/plugins/aiagentgemini/backend/GeminiWebSearch.kt new file mode 100644 index 00000000..a9de1449 --- /dev/null +++ b/plugins/AI-Agent-Gemini/src/main/kotlin/com/itsaky/androidide/plugins/aiagentgemini/backend/GeminiWebSearch.kt @@ -0,0 +1,86 @@ +package com.itsaky.androidide.plugins.aiagentgemini.backend + +import com.itsaky.androidide.plugins.services.LlmInferenceService.LlmConfig +import org.json.JSONArray +import org.json.JSONObject + +/** + * Google Search grounding for the agent's `web_search` tool, which asks for it through + * [LlmConfig.extraParams]. Sent alone, never beside function declarations: Gemini 2.x rejects + * Google Search mixed with function calling, and only Gemini 3 accepts the two together. + */ +internal object GeminiWebSearch { + + /** The key ai-core's `WebAccess.EXTRA_PARAM_WEB_SEARCH` sets; the same literal on both sides. */ + const val EXTRA_PARAM_WEB_SEARCH = "web_search" + + /** + * Put after a report the output cap cut short. Without it the agent read a report ending + * mid-sentence as complete, and wrote a placeholder for the version the cut had dropped. + */ + const val CUT_OFF_NOTE = + "[This report was cut off at the output limit; whatever came after this point is missing.]" + + /** @return whether [config] asks for an answer grounded in a web search. */ + fun isRequested(config: LlmConfig): Boolean = + config.extraParams?.get(EXTRA_PARAM_WEB_SEARCH) == true + + /** + * Declares Google Search as the request's only tool. + * + * @param body a request built by `buildRequestJson` with no tools. + * @return [body], for chaining. + */ + fun declareSearch(body: JSONObject): JSONObject = + body.put("tools", JSONArray().put(JSONObject().put("google_search", JSONObject()))) + + /** + * Every web source link the search returned, once each and in the order given. + * + * @param response the whole generateContent response, holding `groundingMetadata`. + */ + fun sourceUris(response: JSONObject): List = + webSources(response).map { it.first }.distinct() + + /** + * The grounded answer with its sources listed under it, so the agent can cite them or fetch one. + * + * @param text the reply text of the first candidate. + * @param response the whole generateContent response, holding `groundingMetadata`. + * @param resolved each source link's real target (see [GroundingRedirect]); a link missing + * from it is listed as given. + * @return [text], then [CUT_OFF_NOTE] when the cap cut it short, then a "Sources:" list when + * the search returned any. + */ + fun withSources( + text: String, + response: JSONObject, + resolved: Map = emptyMap(), + ): String { + val report = if (wasCutOff(response)) text.trimEnd() + "\n\n" + CUT_OFF_NOTE else text + val sources = webSources(response).map { (uri, title) -> + val link = resolved[uri] ?: uri + if (title == null) "- $link" else "- $title: $link" + }.distinct() + if (sources.isEmpty()) return report + return report.trimEnd() + "\n\nSources:\n" + sources.joinToString("\n") + } + + /** Whether the first candidate stopped at the output cap rather than at its end. */ + private fun wasCutOff(response: JSONObject): Boolean = + response.optJSONArray("candidates")?.optJSONObject(0)?.optString("finishReason") == "MAX_TOKENS" + + /** Each `web` grounding chunk as its link and title, skipping one with no link. */ + private fun webSources(response: JSONObject): List> { + val chunks = response.optJSONArray("candidates") + ?.optJSONObject(0) + ?.optJSONObject("groundingMetadata") + ?.optJSONArray("groundingChunks") + ?: return emptyList() + return (0 until chunks.length()).mapNotNull { index -> + val web = chunks.optJSONObject(index)?.optJSONObject("web") ?: return@mapNotNull null + val uri = web.optString("uri").takeIf { it.isNotBlank() } ?: return@mapNotNull null + uri to web.optString("title").takeIf { it.isNotBlank() } + } + } +} diff --git a/plugins/AI-Agent-Gemini/src/main/kotlin/com/itsaky/androidide/plugins/aiagentgemini/backend/GroundingRedirect.kt b/plugins/AI-Agent-Gemini/src/main/kotlin/com/itsaky/androidide/plugins/aiagentgemini/backend/GroundingRedirect.kt new file mode 100644 index 00000000..62fbca53 --- /dev/null +++ b/plugins/AI-Agent-Gemini/src/main/kotlin/com/itsaky/androidide/plugins/aiagentgemini/backend/GroundingRedirect.kt @@ -0,0 +1,58 @@ +package com.itsaky.androidide.plugins.aiagentgemini.backend + +import java.io.IOException +import java.net.HttpURLConnection +import java.net.URL + +/** + * Where a Google Search grounding source really points. Gemini lists each source as a redirect + * through its own grounding host, so citing the link as given names no page a reader can recognise, + * and fetching it asks the user to approve a host that is not the page's. + */ +internal object GroundingRedirect { + + /** Per request: a source that does not answer quickly is cited as given rather than awaited. */ + private const val TIMEOUT_MS = 5_000 + + /** + * The page [uri] redirects to, read from one `HEAD` that does not follow it. + * + * @param uri the source link as the grounding metadata gave it. + * @param open opens a connection to a URL, or null for a scheme that is not HTTP; a parameter + * so the answer is testable offline. + * @return the redirect's absolute target, or [uri] itself when it does not redirect or the + * request fails. + */ + fun target( + uri: String, + open: (URL) -> HttpURLConnection? = { it.openConnection() as? HttpURLConnection }, + ): String { + val url = try { + URL(uri) + } catch (e: IOException) { + return uri + } + val conn = try { + open(url) + } catch (e: IOException) { + return uri + } ?: return uri + return try { + conn.requestMethod = "HEAD" + conn.instanceFollowRedirects = false + conn.connectTimeout = TIMEOUT_MS + conn.readTimeout = TIMEOUT_MS + val location = conn.getHeaderField("Location") + if (conn.responseCode in 300..399 && !location.isNullOrBlank()) { + URL(url, location).toString() + } else { + uri + } + } catch (e: Exception) { + // One source that fails oddly must not fail the search whose answer it is cited under. + uri + } finally { + conn.disconnect() + } + } +} diff --git a/plugins/AI-Agent-Gemini/src/main/kotlin/com/itsaky/androidide/plugins/aiagentgemini/backend/NetworkTags.kt b/plugins/AI-Agent-Gemini/src/main/kotlin/com/itsaky/androidide/plugins/aiagentgemini/backend/NetworkTags.kt index 45e1084b..03d8ee50 100644 --- a/plugins/AI-Agent-Gemini/src/main/kotlin/com/itsaky/androidide/plugins/aiagentgemini/backend/NetworkTags.kt +++ b/plugins/AI-Agent-Gemini/src/main/kotlin/com/itsaky/androidide/plugins/aiagentgemini/backend/NetworkTags.kt @@ -17,6 +17,9 @@ internal object NetworkTags { /** Batch embedding — `"GEEM"`. */ const val EMBEDDING = 0x4745454D + + /** Resolving a web search's source links — `"GESR"`. */ + const val SEARCH_SOURCES = 0x47455352 } /** diff --git a/plugins/AI-Agent-Gemini/src/main/kotlin/com/itsaky/androidide/plugins/aiagentgemini/errors/GeminiErrorFormatter.kt b/plugins/AI-Agent-Gemini/src/main/kotlin/com/itsaky/androidide/plugins/aiagentgemini/errors/GeminiErrorFormatter.kt index d6765391..0b33b3b3 100644 --- a/plugins/AI-Agent-Gemini/src/main/kotlin/com/itsaky/androidide/plugins/aiagentgemini/errors/GeminiErrorFormatter.kt +++ b/plugins/AI-Agent-Gemini/src/main/kotlin/com/itsaky/androidide/plugins/aiagentgemini/errors/GeminiErrorFormatter.kt @@ -58,6 +58,13 @@ sealed interface GeminiFailure { */ data object ReplyTruncated : GeminiFailure + /** + * Generation ended with neither text nor a tool call for any reason but the output cap. + * + * @property finishReason the stream's `finishReason`, e.g. `MALFORMED_FUNCTION_CALL`, or null. + */ + data class NoReply(val finishReason: String?) : GeminiFailure + /** Everything else, including failures that never reached the network. */ data class Failed(val reason: String?) : GeminiFailure } @@ -92,6 +99,7 @@ internal enum class CredentialFailure(val tag: String, @get:StringRes val messag is GeminiFailure.Unexpected, GeminiFailure.Unreachable, GeminiFailure.ReplyTruncated, + is GeminiFailure.NoReply, is GeminiFailure.Failed -> null } diff --git a/plugins/AI-Agent-Gemini/src/main/kotlin/com/itsaky/androidide/plugins/aiagentgemini/plugin/GeminiPlugin.kt b/plugins/AI-Agent-Gemini/src/main/kotlin/com/itsaky/androidide/plugins/aiagentgemini/plugin/GeminiPlugin.kt index dff4c643..3a5ff5db 100644 --- a/plugins/AI-Agent-Gemini/src/main/kotlin/com/itsaky/androidide/plugins/aiagentgemini/plugin/GeminiPlugin.kt +++ b/plugins/AI-Agent-Gemini/src/main/kotlin/com/itsaky/androidide/plugins/aiagentgemini/plugin/GeminiPlugin.kt @@ -3,13 +3,23 @@ package com.itsaky.androidide.plugins.aiagentgemini.plugin import com.itsaky.androidide.plugins.IPlugin import com.itsaky.androidide.plugins.PluginContext import com.itsaky.androidide.plugins.PluginLifecycleListener +import com.itsaky.androidide.plugins.ai.prompt.AssetPromptConfigSource import com.itsaky.androidide.plugins.aiagentgemini.backend.GeminiBackend import com.itsaky.androidide.plugins.aiagentgemini.preferences.GeminiPreferences +import com.itsaky.androidide.plugins.aiagentgemini.prompt.GeminiSystemPrompt +import com.itsaky.androidide.plugins.aiagentgemini.prompt.config.GeminiPromptConfig +import com.itsaky.androidide.plugins.aiagentgemini.prompt.config.sharedPromptConfig import com.itsaky.androidide.plugins.extensions.DocumentationExtension import com.itsaky.androidide.plugins.extensions.PluginTooltipButton import com.itsaky.androidide.plugins.extensions.PluginTooltipEntry import com.itsaky.androidide.plugins.services.LlmInferenceService import com.itsaky.androidide.plugins.services.SharedServices +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.cancel /** * Registers the Google Gemini API backend with AI Core's inference router. @@ -32,6 +42,9 @@ class GeminiPlugin : IPlugin, DocumentationExtension { /** True once [backend] is registered with the router, so re-registration is idempotent. */ @Volatile private var registered = false + /** Runs the prompt-config load while the plugin is active; cancelled on deactivation. */ + @Volatile private var configScope: CoroutineScope? = null + companion object { const val PLUGIN_ID = "com.itsaky.androidide.plugins.aiagentgemini" @@ -116,8 +129,9 @@ class GeminiPlugin : IPlugin, DocumentationExtension { // A half-failed activation can leave a backend behind; keep at most one live. releaseBackend() + preloadPromptConfig() - val gemini = GeminiBackend(context) + val gemini = GeminiBackend(context, sharedPromptConfig::configIfLoaded) backend = gemini activeBackend = gemini @@ -178,6 +192,45 @@ class GeminiPlugin : IPlugin, DocumentationExtension { null } + /** Reads and validates the prompt config now, so building a prompt does no disk I/O. */ + @OptIn(ExperimentalCoroutinesApi::class) + private fun preloadPromptConfig() { + releasePromptConfig() + val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default) + configScope = scope + val source = AssetPromptConfigSource(context.androidContext.assets) + val load = sharedPromptConfig.preload(scope, source) + load.invokeOnCompletion { error -> + when (error) { + null -> reportLoadedConfig(load.getCompleted()) + is CancellationException -> Unit + else -> context.logger.error( + "GeminiPlugin: prompt config failed to load; ai-core's default prompt is sent instead", + error, + ) + } + } + } + + /** + * Logs that the config loaded, and any name typo its layout would hit at render time. + * + * @param config the config just loaded. + */ + private fun reportLoadedConfig(config: GeminiPromptConfig) { + context.logger.info("GeminiPlugin: loaded prompt config with ${config.rules.size} rule groups") + for (problem in GeminiSystemPrompt.problems(config)) { + context.logger.warn("GeminiPlugin: $problem; ai-core's default prompt is sent instead") + } + } + + /** Drops the cached config and stops a load still in flight. Idempotent. */ + private fun releasePromptConfig() { + sharedPromptConfig.clear() + configScope?.cancel() + configScope = null + } + override fun deactivate(): Boolean { context.logger.info("GeminiPlugin: Deactivating plugin") @@ -193,6 +246,7 @@ class GeminiPlugin : IPlugin, DocumentationExtension { // A disabled plugin must not keep the decrypted key on the host heap. releaseBackend() + releasePromptConfig() true } catch (e: Exception) { @@ -219,6 +273,7 @@ class GeminiPlugin : IPlugin, DocumentationExtension { runCatching { context.removePluginLifecycleListener(aiCoreLifecycle) } releaseBackend() + releasePromptConfig() pluginContext = null context.logger.info("GeminiPlugin: Released Gemini backend") } diff --git a/plugins/AI-Agent-Gemini/src/main/kotlin/com/itsaky/androidide/plugins/aiagentgemini/prompt/GeminiPromptVariables.kt b/plugins/AI-Agent-Gemini/src/main/kotlin/com/itsaky/androidide/plugins/aiagentgemini/prompt/GeminiPromptVariables.kt new file mode 100644 index 00000000..c5a5cf1c --- /dev/null +++ b/plugins/AI-Agent-Gemini/src/main/kotlin/com/itsaky/androidide/plugins/aiagentgemini/prompt/GeminiPromptVariables.kt @@ -0,0 +1,108 @@ +package com.itsaky.androidide.plugins.aiagentgemini.prompt + +import com.itsaky.androidide.plugins.aiagentgemini.prompt.config.GeminiPromptConfig +import com.itsaky.androidide.plugins.services.LlmInferenceService.SystemPromptRequest + +/** + * The values the prompt config is rendered with: its texts, named by YAML path, and the request's. + * Every key is always present, empty when it does not apply, so a name missing here is a typo in + * a file and fails the render instead of silently dropping text. + */ +internal object GeminiPromptVariables { + + // Config texts: `identity` is IDENTITY, `scope.heading` is SCOPE_HEADING, and so on. + const val IDENTITY = "IDENTITY" + const val SCOPE_HEADING = "SCOPE_HEADING" + const val SCOPE_ITEMS = "SCOPE_ITEMS" + const val RULES = "RULES" + const val HEADING = "HEADING" + const val ITEMS = "ITEMS" + const val TEXT = "TEXT" + const val BEHAVIOR_HEADING = "BEHAVIOR_HEADING" + const val BEHAVIOR_ITEMS = "BEHAVIOR_ITEMS" + const val WORKFLOW_HEADING = "WORKFLOW_HEADING" + const val WORKFLOW_STEPS = "WORKFLOW_STEPS" + const val WORKFLOW_CLOSING = "WORKFLOW_CLOSING" + const val TOOLS_HEADING = "TOOLS_HEADING" + const val TOOL_CALL_FORMAT_NO_NARRATION = "TOOL_CALL_FORMAT_NO_NARRATION" + const val TOOL_CALL_FORMAT_NATIVE = "TOOL_CALL_FORMAT_NATIVE" + const val TOOL_CALL_FORMAT_TEXT_INSTRUCTION = "TOOL_CALL_FORMAT_TEXT_INSTRUCTION" + const val TOOL_CALL_FORMAT_TEXT_ONLY_THE_LINE_RUNS = "TOOL_CALL_FORMAT_TEXT_ONLY_THE_LINE_RUNS" + const val TOOL_CALL_FORMAT_TEXT_EXAMPLES_HEADING = "TOOL_CALL_FORMAT_TEXT_EXAMPLES_HEADING" + const val TOOL_CALL_FORMAT_TEXT_EXAMPLES = "TOOL_CALL_FORMAT_TEXT_EXAMPLES" + const val PURPOSE = "PURPOSE" + const val CALL = "CALL" + + /** A workflow step's 1-based position, inside `{{#WORKFLOW_STEPS}}`. */ + const val NUMBER = "NUMBER" + + /** The tools the request offers, each with [NAME] and [DESCRIPTION]. */ + const val TOOLS = "TOOLS" + + /** The tool-call envelope; null when calls travel through the function-calling API. */ + const val TOOL_CALL_SYNTAX = "TOOL_CALL_SYNTAX" + + /** Whether calls travel through the function-calling API rather than the reply text. */ + const val NATIVE_TOOL_CALLS = "NATIVE_TOOL_CALLS" + + /** A real project path to show in examples. */ + const val EXAMPLE_FILE_PATH = "EXAMPLE_FILE_PATH" + + /** [EXAMPLE_FILE_PATH]'s file name without folder or extension, for search examples. */ + const val EXAMPLE_FILE_STEM = "EXAMPLE_FILE_STEM" + + /** A tool's name, inside `{{#TOOLS}}`. */ + const val NAME = "NAME" + + /** A tool's description, inside `{{#TOOLS}}`. */ + const val DESCRIPTION = "DESCRIPTION" + + /** Path used in examples when the caller names none, so they still show a concrete shape. */ + const val FALLBACK_EXAMPLE_PATH = "app/src/main/java/com/example/MainActivity.kt" + + /** + * Collects every value `layout.system_prompt` may use. + * + * @param config the loaded prompt config. + * @param request the tool list, envelope syntax and example path to describe. + * @return the values, keyed by name. + */ + fun collect(config: GeminiPromptConfig, request: SystemPromptRequest): Map { + val examplePath = request.exampleFilePath ?: FALLBACK_EXAMPLE_PATH + val format = config.toolCallFormat + return mapOf( + IDENTITY to config.identity, + SCOPE_HEADING to config.scope.heading, + SCOPE_ITEMS to config.scope.items.map { mapOf(TEXT to it) }, + RULES to config.rules.map { group -> + mapOf(HEADING to group.heading, ITEMS to group.items.map { mapOf(TEXT to it) }) + }, + BEHAVIOR_HEADING to config.behavior.heading, + BEHAVIOR_ITEMS to config.behavior.items.map { mapOf(TEXT to it) }, + WORKFLOW_HEADING to config.workflow.heading, + WORKFLOW_STEPS to config.workflow.steps.mapIndexed { index, step -> + mapOf(NUMBER to (index + 1).toString(), TEXT to step) + }, + WORKFLOW_CLOSING to config.workflow.closing, + TOOLS_HEADING to config.tools.heading, + TOOL_CALL_FORMAT_NO_NARRATION to format.noNarration, + TOOL_CALL_FORMAT_NATIVE to format.native, + TOOL_CALL_FORMAT_TEXT_INSTRUCTION to format.text.instruction, + TOOL_CALL_FORMAT_TEXT_ONLY_THE_LINE_RUNS to format.text.onlyTheLineRuns, + TOOL_CALL_FORMAT_TEXT_EXAMPLES_HEADING to format.text.examplesHeading, + TOOL_CALL_FORMAT_TEXT_EXAMPLES to format.text.examples.map { + mapOf(PURPOSE to it.purpose, CALL to it.call) + }, + // Plain Strings, so a contributed tool's description is never rendered as a template. + TOOLS to request.tools.map { mapOf(NAME to it.name, DESCRIPTION to it.description) }, + EXAMPLE_FILE_PATH to examplePath, + // A dotfile's name is all extension, so its stem would be an empty search. + EXAMPLE_FILE_STEM to examplePath.substringAfterLast('/').let { name -> + name.substringBeforeLast('.').ifEmpty { name } + }, + // Null syntax: calls arrive via the function-calling API, not the text (ADFA-5410). + TOOL_CALL_SYNTAX to request.toolCallSyntax, + NATIVE_TOOL_CALLS to (request.toolCallSyntax == null), + ) + } +} diff --git a/plugins/AI-Agent-Gemini/src/main/kotlin/com/itsaky/androidide/plugins/aiagentgemini/prompt/GeminiSystemPrompt.kt b/plugins/AI-Agent-Gemini/src/main/kotlin/com/itsaky/androidide/plugins/aiagentgemini/prompt/GeminiSystemPrompt.kt index 7db34ae3..ed9ebb97 100644 --- a/plugins/AI-Agent-Gemini/src/main/kotlin/com/itsaky/androidide/plugins/aiagentgemini/prompt/GeminiSystemPrompt.kt +++ b/plugins/AI-Agent-Gemini/src/main/kotlin/com/itsaky/androidide/plugins/aiagentgemini/prompt/GeminiSystemPrompt.kt @@ -1,106 +1,53 @@ package com.itsaky.androidide.plugins.aiagentgemini.prompt +import com.itsaky.androidide.plugins.ai.prompt.PromptTemplateEngine +import com.itsaky.androidide.plugins.aiagentgemini.prompt.config.GeminiPromptConfig import com.itsaky.androidide.plugins.services.LlmInferenceService.SystemPromptRequest +import com.itsaky.androidide.plugins.services.LlmInferenceService.ToolDefinition /** - * The system prompt this backend asks for. - * - * Written for a large cloud model: it states a goal and a workflow and trusts the model to plan - * within them, where a small on-device model needs each step spelled out. That difference is a - * property of the model, so the prompt lives with the backend that talks to it. - * - * Pure and free of Android types, so it is unit-testable without a device or a network. + * The system prompt this backend asks for: `layout.yml`'s `system_prompt`, rendered in one pass. + * Written for a large cloud model, so the wording lives with the backend that talks to it; knows + * no wording itself, which is the config's. Pure and thread-safe. */ internal object GeminiSystemPrompt { /** - * Path used in the examples when the caller names none, so they still show a concrete shape. + * Builds the prompt; the envelope and its examples appear only when the caller parses text. + * + * @param request the tool list, envelope syntax and example path to describe. + * @param config the loaded prompt config. + * @return the system prompt, without the caller's IDE-context block. */ - private const val FALLBACK_EXAMPLE_PATH = "app/src/main/java/com/example/MainActivity.kt" - - /** How to call a tool when the caller takes calls through the provider's own API. */ - private val NATIVE_CALL_FORMAT = """ - TOOL CALL FORMAT — the tools above are declared to you: call one through the function-calling - API. A call written into your reply text is NOT read by this system and will not run. - Do NOT describe the action in prose (e.g. "Okay, I'll open the file…") — narrating does nothing. - """.trimIndent() + fun build(request: SystemPromptRequest, config: GeminiPromptConfig): String = + PromptTemplateEngine.render(config.layout.systemPrompt, GeminiPromptVariables.collect(config, request)) + .trimEnd() /** - * Builds the prompt for [request]. - * - * [SystemPromptRequest.toolCallSyntax] is reproduced verbatim — a paraphrase would produce - * replies nothing reads — and a null one means the caller parses no envelope, so the format - * section and its examples are left out rather than taught in a syntax nothing reads back. + * Renders requests that open and close every section, to catch a name typo. * - * @return the system prompt, without the caller's IDE-context block + * @param config the loaded prompt config. + * @return one message per distinct failure; empty when every request renders. */ - fun build(request: SystemPromptRequest): String { - val toolDescriptions = request.tools.joinToString("\n") { "- ${it.name}: ${it.description}" } - val examplePath = request.exampleFilePath ?: FALLBACK_EXAMPLE_PATH - val exampleStem = examplePath.substringAfterLast('/').substringBeforeLast('.') - - val head = """ - You are a senior Android developer integrated into CodeOnTheGo. Your goal is to build complete, working Android apps from user descriptions. - - AVAILABLE TOOLS: - $toolDescriptions - - BEHAVIOR: - - Create complete, production-ready code - - Call tools proactively to build, test, and verify your work - - Read files to understand project structure before making changes - - After each file modification, verify the build compiles - - Generate apps that actually run and work as described - - RULES: - - Emit ONE tool call per reply, then stop and wait. Do NOT plan a batch: a tool whose arguments depend on another tool's result (editing a file you just searched for) cannot use a result you have not received yet. - - To locate a file, call search_project ONCE with its name — it searches the whole project. Never walk the tree with repeated list_files calls; you have a limited number of turns and each level wastes one. - - Renaming a symbol everywhere in a file is ONE edit_file with replace_all set to true and old_string set to just the symbol — not one edit per line. - - To change an existing file, use edit_file (find/replace an exact snippet), not update_file — a whole-file rewrite gets truncated before it reaches disk. - - Before edit_file, read the exact file you are about to edit with read_file, and copy old_string byte-for-byte from that output, including indentation. Never edit a path you have not confirmed exists. - - old_string must be the text currently in the file and new_string what it should become. If they are identical the edit is rejected. - - Never fabricate tool output. Emit a tool call, then wait for the real result before continuing. - - Never write "User:", "Assistant:", a block, or a ```tool_response fence — the system supplies real results. Any tool output you write yourself is a hallucination and will be ignored. - - 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, either give a short summary with no tool call, or end with a single respond call carrying that summary in its "message" — never an empty respond. - """.trimIndent() - - val workflow = """ - WORKFLOW: - 1. Understand the user's request - 2. Locate what you need with ONE search_project call — the IDE CONTEXT block above already names the source, layout and manifest paths - 3. Create/modify files with complete implementations - 4. Add dependencies if needed - 5. Sync gradle and verify compilation - 6. Run the app to confirm it works - 7. Report success and what was built - """.trimIndent() - - // Null syntax means the caller reads calls off the function-calling API instead. Saying so - // is what stops the model writing one as text, where nothing would run it (ADFA-5410). - val syntax = request.toolCallSyntax ?: return listOf(head, NATIVE_CALL_FORMAT, workflow) - .joinToString("\n\n") - - val callFormat = """ - TOOL CALL FORMAT — to run a tool, emit a single line in EXACTLY this format and nothing after it: - $syntax - 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 tool call line itself. - - FORMAT EXAMPLES (the tool call is the entire reply; the paths are this project's — reuse a path - only when it is the file you actually mean): - Report the finished task (the summary goes in "message"): - {"tool":"respond","args":{"message":"Renamed count to itemCount."}} - Open a file once you know its path: - {"tool":"open_file","args":{"file_path":"$examplePath"}} - Find a file by name: - {"tool":"search_project","args":{"query":"$exampleStem"}} - List the project's top-level files (an empty directory means the project root): - {"tool":"list_files","args":{"directory":""}} - Change part of a file (line breaks inside a value MUST be written as \n): - {"tool":"edit_file","args":{"file_path":"$examplePath","old_string":"count = 0","new_string":"count = 1"}} - """.trimIndent() - - return head + "\n\n" + callFormat + "\n\n" + workflow + fun problems(config: GeminiPromptConfig): List = + CHECK_REQUESTS.mapNotNull { request -> + try { + build(request, config) + null + } catch (e: IllegalArgumentException) { + e.message + } + }.distinct() + + /** Text and native calling, two tools and none, a real path and the fallback. */ + private val CHECK_REQUESTS: List = run { + val tools = listOf( + ToolDefinition("read_file", "Read a file.", emptyMap()), + ToolDefinition("respond", "Reply.", emptyMap()), + ) + listOf( + SystemPromptRequest(tools, "…", "app/Main.kt"), + SystemPromptRequest(emptyList(), null, null), + ) } } diff --git a/plugins/AI-Agent-Gemini/src/main/kotlin/com/itsaky/androidide/plugins/aiagentgemini/prompt/config/GeminiPromptConfig.kt b/plugins/AI-Agent-Gemini/src/main/kotlin/com/itsaky/androidide/plugins/aiagentgemini/prompt/config/GeminiPromptConfig.kt new file mode 100644 index 00000000..2b488caf --- /dev/null +++ b/plugins/AI-Agent-Gemini/src/main/kotlin/com/itsaky/androidide/plugins/aiagentgemini/prompt/config/GeminiPromptConfig.kt @@ -0,0 +1,90 @@ +package com.itsaky.androidide.plugins.aiagentgemini.prompt.config + +import com.itsaky.androidide.plugins.ai.prompt.PromptConfigLoader +import com.itsaky.androidide.plugins.ai.prompt.PromptText + + +/** + * Gemini's system prompt as `assets/prompts/` declares it: the wording, and the layout that + * arranges it. Loaded by [PromptConfigLoader]; immutable, so one instance serves every request. + * + * @property identity who the agent is. + * @property scope what the agent will answer. + * @property rules the rules, highest priority first. + * @property behavior how to go about building or changing something. + * @property workflow the steps of such a task, in order. + * @property tools the wording around the tool list. + * @property toolCallFormat how to call a tool, natively or as text. + * @property layout where each text goes. + */ +data class GeminiPromptConfig( + val identity: PromptText, + val scope: Section, + val rules: List, + val behavior: Section, + val workflow: Workflow, + val tools: Tools, + val toolCallFormat: ToolCallFormat, + val layout: Layout, +) { + + /** + * A heading and the lines under it. + * + * @property heading what the lines are about. + * @property items one sentence each. + */ + data class Section(val heading: PromptText, val items: List) + + /** + * One priority's rules. + * + * @property heading the priority's name, e.g. `CRITICAL`. + * @property items the rules, one sentence each. + */ + data class RuleGroup(val heading: PromptText, val items: List) + + /** + * @property heading what introduces the steps. + * @property steps the steps, numbered in order when rendered. + * @property closing when to skip them. + */ + data class Workflow(val heading: PromptText, val steps: List, val closing: PromptText) + + /** @property heading what introduces the tool list. */ + data class Tools(val heading: PromptText) + + /** + * @property noNarration sent under either format: acting means calling, not describing. + * @property native how to call under the function-calling API. + * @property text how to call when calls travel in the reply. + */ + data class ToolCallFormat(val noNarration: PromptText, val native: PromptText, val text: TextFormat) + + /** + * @property instruction the sentence introducing the envelope. + * @property onlyTheLineRuns that only the envelope line itself runs a tool. + * @property examplesHeading what introduces [examples]. + * @property examples well-formed calls, each with what it is for. + */ + data class TextFormat( + val instruction: PromptText, + val onlyTheLineRuns: PromptText, + val examplesHeading: PromptText, + val examples: List, + ) + + /** + * @property purpose what the call does. + * @property call the call, as the model should write it. + */ + data class Example(val purpose: PromptText, val call: PromptText) + + /** @property systemPrompt the whole prompt; ai-core appends its IDE CONTEXT block after it. */ + data class Layout(val systemPrompt: PromptText) + + companion object { + /** The `schema_version` this code reads; bump it when a key is renamed or removed. */ + const val SCHEMA_VERSION = 1 + } +} diff --git a/plugins/AI-Agent-Gemini/src/main/kotlin/com/itsaky/androidide/plugins/aiagentgemini/prompt/config/GeminiPromptConfigParser.kt b/plugins/AI-Agent-Gemini/src/main/kotlin/com/itsaky/androidide/plugins/aiagentgemini/prompt/config/GeminiPromptConfigParser.kt new file mode 100644 index 00000000..cd3b15fa --- /dev/null +++ b/plugins/AI-Agent-Gemini/src/main/kotlin/com/itsaky/androidide/plugins/aiagentgemini/prompt/config/GeminiPromptConfigParser.kt @@ -0,0 +1,64 @@ +package com.itsaky.androidide.plugins.aiagentgemini.prompt.config + +import com.itsaky.androidide.plugins.ai.prompt.PromptConfigDocument +import com.itsaky.androidide.plugins.ai.prompt.PromptConfigException +import com.itsaky.androidide.plugins.ai.prompt.PromptConfigLoader +import com.itsaky.androidide.plugins.ai.prompt.PromptConfigObject +import com.itsaky.androidide.plugins.ai.prompt.PromptConfigParser +import com.itsaky.androidide.plugins.aiagentgemini.prompt.config.GeminiPromptConfig.Example +import com.itsaky.androidide.plugins.aiagentgemini.prompt.config.GeminiPromptConfig.Layout +import com.itsaky.androidide.plugins.aiagentgemini.prompt.config.GeminiPromptConfig.RuleGroup +import com.itsaky.androidide.plugins.aiagentgemini.prompt.config.GeminiPromptConfig.Section +import com.itsaky.androidide.plugins.aiagentgemini.prompt.config.GeminiPromptConfig.TextFormat +import com.itsaky.androidide.plugins.aiagentgemini.prompt.config.GeminiPromptConfig.ToolCallFormat +import com.itsaky.androidide.plugins.aiagentgemini.prompt.config.GeminiPromptConfig.Tools +import com.itsaky.androidide.plugins.aiagentgemini.prompt.config.GeminiPromptConfig.Workflow + +/** + * Maps the merged config onto a [GeminiPromptConfig]. Strict: a missing, mistyped or unknown key + * throws [PromptConfigException] naming the file that holds it, so a typo fails on activation. + */ +object GeminiPromptConfigParser : PromptConfigParser { + + /** + * Parses the config merged from `agent.yml` and its includes; see [PromptConfigLoader]. + * + * @param document the merged top-level keys and the file each came from. + * @return the config. + */ + override fun parse(document: PromptConfigDocument): GeminiPromptConfig = + document.read { + val version = int("schema_version") + if (version != GeminiPromptConfig.SCHEMA_VERSION) { + val supported = GeminiPromptConfig.SCHEMA_VERSION + throw invalid("schema_version", "is $version, but this Gemini plugin reads $supported") + } + GeminiPromptConfig( + identity = text("identity"), + scope = obj("scope").read { section() }, + rules = objects("rules").map { it.read { RuleGroup(text("heading"), texts("items")) } }, + behavior = obj("behavior").read { section() }, + workflow = obj("workflow").read { Workflow(text("heading"), texts("steps"), text("closing")) }, + tools = obj("tools").read { Tools(text("heading")) }, + toolCallFormat = obj("tool_call_format").read { + ToolCallFormat( + noNarration = text("no_narration"), + native = text("native"), + text = obj("text").read { + TextFormat( + instruction = text("instruction"), + onlyTheLineRuns = text("only_the_line_runs"), + examplesHeading = text("examples_heading"), + examples = objects("examples").map { example -> + example.read { Example(text("purpose"), text("call")) } + }, + ) + }, + ) + }, + layout = obj("layout").read { Layout(text("system_prompt")) }, + ) + } + + private fun PromptConfigObject.section() = Section(text("heading"), texts("items")) +} diff --git a/plugins/AI-Agent-Gemini/src/main/kotlin/com/itsaky/androidide/plugins/aiagentgemini/prompt/config/SharedPromptConfig.kt b/plugins/AI-Agent-Gemini/src/main/kotlin/com/itsaky/androidide/plugins/aiagentgemini/prompt/config/SharedPromptConfig.kt new file mode 100644 index 00000000..f8559852 --- /dev/null +++ b/plugins/AI-Agent-Gemini/src/main/kotlin/com/itsaky/androidide/plugins/aiagentgemini/prompt/config/SharedPromptConfig.kt @@ -0,0 +1,8 @@ +package com.itsaky.androidide.plugins.aiagentgemini.prompt.config + +import com.itsaky.androidide.plugins.ai.prompt.PromptConfigStore + + + +/** This plugin's prompt config, filled on activation and read by every chat turn. */ +val sharedPromptConfig: PromptConfigStore = PromptConfigStore(GeminiPromptConfigParser) diff --git a/plugins/AI-Agent-Gemini/src/main/kotlin/com/itsaky/androidide/plugins/aiagentgemini/settings/GeminiSettingsFragment.kt b/plugins/AI-Agent-Gemini/src/main/kotlin/com/itsaky/androidide/plugins/aiagentgemini/settings/GeminiSettingsFragment.kt index bfd89fef..2a3aed6f 100644 --- a/plugins/AI-Agent-Gemini/src/main/kotlin/com/itsaky/androidide/plugins/aiagentgemini/settings/GeminiSettingsFragment.kt +++ b/plugins/AI-Agent-Gemini/src/main/kotlin/com/itsaky/androidide/plugins/aiagentgemini/settings/GeminiSettingsFragment.kt @@ -29,10 +29,14 @@ import androidx.lifecycle.lifecycleScope import com.google.android.material.dialog.MaterialAlertDialogBuilder import com.google.android.material.textfield.TextInputLayout import com.itsaky.androidide.plugins.PluginContext +import com.itsaky.androidide.plugins.ai.ui.ButtonColors +import com.itsaky.androidide.plugins.ai.ui.FieldColors +import com.itsaky.androidide.plugins.ai.ui.PaneStyle +import com.itsaky.androidide.plugins.ai.ui.RevealToggle +import com.itsaky.androidide.plugins.ai.ui.SecretRevealController +import com.itsaky.androidide.plugins.ai.ui.applyPaneStyling import com.itsaky.androidide.plugins.aiagentgemini.plugin.GeminiPlugin import com.itsaky.androidide.plugins.aiagentgemini.R -import com.itsaky.androidide.plugins.aiagentgemini.ui.SecretRevealController -import com.itsaky.androidide.plugins.aiagentgemini.ui.applyPaneStyling import com.itsaky.androidide.plugins.base.PluginFragmentHelper import com.itsaky.androidide.plugins.security.KeystoreSecretStore import com.itsaky.androidide.plugins.services.IdeTooltipService @@ -49,6 +53,30 @@ private val OUTLINED_BUTTON_IDS = setOf( R.id.btn_refresh_models, ) +/** This plugin's resources for [applyPaneStyling]. */ +private val PANE_STYLE = PaneStyle( + filledButton = ButtonColors( + content = R.color.plugin_button_filled_content, + ripple = R.color.plugin_button_filled_ripple, + container = R.color.plugin_button_filled_container, + ), + outlinedButton = ButtonColors( + content = R.color.plugin_button_outlined_content, + ripple = R.color.plugin_button_outlined_ripple, + stroke = R.color.plugin_button_outlined_stroke, + ), + field = FieldColors( + stroke = R.color.plugin_box_stroke, + error = R.color.plugin_error, + hint = R.color.plugin_text_muted, + endIcon = R.color.plugin_on_surface_variant, + ), + divider = R.color.plugin_outline_variant, + buttonStrokeWidth = R.dimen.button_stroke_width, + cornerRadius = R.dimen.radius_md, + dividerThickness = R.dimen.divider_thickness, +) + /** * This backend's settings pane, mounted by whichever screen offers a backend selector. * @@ -117,7 +145,7 @@ class GeminiSettingsFragment : Fragment() { GeminiSettingsViewModelFactory { GeminiPlugin.getContext() } )[GeminiSettingsViewModel::class.java] - view.applyPaneStyling(OUTLINED_BUTTON_IDS) + view.applyPaneStyling(PANE_STYLE, OUTLINED_BUTTON_IDS) setupApiKeyUi(view) setupModelPicker(view, chatModelPicker()) setupModelPicker(view, embeddingModelPicker()) @@ -295,7 +323,12 @@ class GeminiSettingsFragment : Fragment() { // The window is flagged secure for exactly as long as the key is legible, which is why the // click is owned here rather than left to endIconMode="password_toggle". - val reveal = SecretRevealController(apiKeyBox, apiKeyInput) { legible -> + val reveal = SecretRevealController( + apiKeyBox, + apiKeyInput, + reveal = RevealToggle(R.drawable.ic_visibility, R.string.cd_show_credential), + hide = RevealToggle(R.drawable.ic_visibility_off, R.string.cd_hide_credential), + ) { legible -> setSecureWindow(legible) } reveal.attach() diff --git a/plugins/AI-Agent-Gemini/src/main/kotlin/com/itsaky/androidide/plugins/aiagentgemini/ui/PaneStyling.kt b/plugins/AI-Agent-Gemini/src/main/kotlin/com/itsaky/androidide/plugins/aiagentgemini/ui/PaneStyling.kt deleted file mode 100644 index 75ce661e..00000000 --- a/plugins/AI-Agent-Gemini/src/main/kotlin/com/itsaky/androidide/plugins/aiagentgemini/ui/PaneStyling.kt +++ /dev/null @@ -1,77 +0,0 @@ -package com.itsaky.androidide.plugins.aiagentgemini.ui - -import android.content.res.ColorStateList -import android.graphics.Color -import android.view.View -import android.view.ViewGroup -import androidx.annotation.ColorRes -import androidx.core.content.ContextCompat -import com.google.android.material.button.MaterialButton -import com.google.android.material.divider.MaterialDivider -import com.google.android.material.textfield.TextInputLayout -import com.itsaky.androidide.plugins.aiagentgemini.R - -/** How much a pane button stands out: one filled action per section, the rest outlined. */ -private enum class ButtonEmphasis { FILLED, OUTLINED } - -/** - * Gives every Material button, text field and divider under [this] its Material 3 colours, outline - * and ripple in code. The styles' `app:` items are dropped inside the host, so XML alone leaves - * these controls on the host theme's values. - * - * @param outlinedButtonIds the buttons that are secondary actions; every other button is filled. - */ -internal fun View.applyPaneStyling(outlinedButtonIds: Set) { - when (this) { - is MaterialButton -> applyEmphasis( - if (id in outlinedButtonIds) ButtonEmphasis.OUTLINED else ButtonEmphasis.FILLED - ) - is TextInputLayout -> applyOutline() - is MaterialDivider -> applyHairline() - } - if (this is ViewGroup) { - for (i in 0 until childCount) getChildAt(i).applyPaneStyling(outlinedButtonIds) - } -} - -/** Container, label, icon, border and ripple for [emphasis], each with its disabled state. */ -private fun MaterialButton.applyEmphasis(emphasis: ButtonEmphasis) { - val filled = emphasis == ButtonEmphasis.FILLED - val content = colors( - if (filled) R.color.plugin_button_filled_content else R.color.plugin_button_outlined_content - ) - backgroundTintList = if (filled) { - colors(R.color.plugin_button_filled_container) - } else { - ColorStateList.valueOf(Color.TRANSPARENT) - } - setTextColor(content) - iconTint = content - rippleColor = colors( - if (filled) R.color.plugin_button_filled_ripple else R.color.plugin_button_outlined_ripple - ) - strokeColor = colors(R.color.plugin_button_outlined_stroke) - strokeWidth = if (filled) 0 else resources.getDimensionPixelSize(R.dimen.button_stroke_width) - cornerRadius = resources.getDimensionPixelSize(R.dimen.radius_md) -} - -/** Outline, corners, hint and end icon of an outlined-box field. */ -private fun TextInputLayout.applyOutline() { - setBoxStrokeColorStateList(colors(R.color.plugin_box_stroke)) - setBoxStrokeErrorColor(colors(R.color.plugin_error)) - val radius = resources.getDimension(R.dimen.radius_md) - setBoxCornerRadii(radius, radius, radius, radius) - val hint = colors(R.color.plugin_text_muted) - defaultHintTextColor = hint - hintTextColor = hint - setEndIconTintList(colors(R.color.plugin_on_surface_variant)) -} - -private fun MaterialDivider.applyHairline() { - setDividerColorResource(R.color.plugin_outline_variant) - setDividerThicknessResource(R.dimen.divider_thickness) -} - -/** Resolved against this view's context, which carries the plugin's resources. */ -private fun View.colors(@ColorRes id: Int): ColorStateList = - requireNotNull(ContextCompat.getColorStateList(context, id)) diff --git a/plugins/AI-Agent-Gemini/src/main/kotlin/com/itsaky/androidide/plugins/aiagentgemini/ui/SecretRevealController.kt b/plugins/AI-Agent-Gemini/src/main/kotlin/com/itsaky/androidide/plugins/aiagentgemini/ui/SecretRevealController.kt deleted file mode 100644 index ac8d5f63..00000000 --- a/plugins/AI-Agent-Gemini/src/main/kotlin/com/itsaky/androidide/plugins/aiagentgemini/ui/SecretRevealController.kt +++ /dev/null @@ -1,106 +0,0 @@ -package com.itsaky.androidide.plugins.aiagentgemini.ui - -import android.text.method.HideReturnsTransformationMethod -import android.text.method.PasswordTransformationMethod -import android.view.Choreographer -import android.widget.EditText -import com.google.android.material.textfield.TextInputLayout -import com.itsaky.androidide.plugins.aiagentgemini.R - -/** - * The reveal control for this plugin's masked credential field. - * - * The control is the field's own [TextInputLayout] end icon rather than a loose `ImageButton`, - * which is what gives it a real touch target wherever the field is shown. Icon, content - * description and toggle behaviour are decided here and nowhere else, so this pane cannot drift - * from the other AI plugins' panes (ADFA-5491). - * - * Deliberately one copy per AI plugin: each addon is an independent Gradle build sharing only the - * repo's `libs/` jars, so there is nowhere cheaper to put this until the host's plugin-api carries - * it — a change to the masking logic is three edits, on purpose. - * - * @param box the field's own layout, whose end icon becomes the control - * @param field the masked field - * @param onLegibleChanged called with true while the secret stands in clear text, so the caller can - * flag its window secure — which window that is depends on the screen, not on this control - */ -internal class SecretRevealController( - private val box: TextInputLayout, - private val field: EditText, - private val onLegibleChanged: (legible: Boolean) -> Unit, -) { - - /** Whether the secret currently stands in clear text. */ - var isRevealed: Boolean = false - private set - - /** - * Take over [box]'s end icon and mask the field. - * - * The drawable is set here rather than in the layout because an end icon declared as - * `app:endIconDrawable` draws blank inside the host. - */ - fun attach() { - box.endIconMode = TextInputLayout.END_ICON_CUSTOM - // Not announced as a toggle: with END_ICON_CUSTOM nothing ever moves the icon's checked - // state, so TalkBack would read "not checked" over a legible secret. The content - // description below carries the state instead. - box.isEndIconCheckable = false - box.setEndIconOnClickListener { toggle() } - apply() - } - - /** - * Re-mask the secret and report it illegible. - * - * Called when the pane leaves the foreground as well as when a new secret is loaded, so - * neither a screenshot nor the recents thumbnail can catch a revealed credential. - */ - fun mask() { - if (!isRevealed) return - isRevealed = false - apply() - } - - private fun toggle() { - isRevealed = !isRevealed - apply() - } - - /** Dress the field and its icon for [isRevealed], then report what is now legible. */ - private fun apply() { - field.transformationMethod = if (isRevealed) { - HideReturnsTransformationMethod.getInstance() - } else { - PasswordTransformationMethod.getInstance() - } - box.setEndIconDrawable( - if (isRevealed) R.drawable.ic_visibility_off else R.drawable.ic_visibility - ) - box.setEndIconContentDescription( - if (isRevealed) R.string.cd_hide_credential else R.string.cd_show_credential - ) - // Swapping the transformation drops the cursor to the start, so typing would continue in - // front of the key rather than after it. - field.setSelection(field.text?.length ?: 0) - // Masking only invalidates: the secret stays on screen until the next frame is drawn. - if (isRevealed) { - onLegibleChanged(true) - } else { - afterNextDraw { if (!isRevealed) onLegibleChanged(false) } - } - } - - /** - * Run [action] once the next frame has been drawn, or right away if the field is already gone: - * a frame callback runs before that frame's traversal, so a message posted from it lands after - * the field has been redrawn. - */ - private fun afterNextDraw(action: () -> Unit) { - if (!field.isAttachedToWindow) { - action() - return - } - Choreographer.getInstance().postFrameCallback { field.post(action) } - } -} diff --git a/plugins/AI-Agent-Gemini/src/main/res/values/strings.xml b/plugins/AI-Agent-Gemini/src/main/res/values/strings.xml index ae625c5c..e3abc351 100644 --- a/plugins/AI-Agent-Gemini/src/main/res/values/strings.xml +++ b/plugins/AI-Agent-Gemini/src/main/res/values/strings.xml @@ -10,7 +10,11 @@ Gemini is temporarily unavailable (HTTP %1$d). Try again in a moment. Gemini returned an error (HTTP %1$d). Gemini returned an error (HTTP %1$d). %2$s - The reply hit the model\'s output limit before the action was complete, so nothing was changed. Ask for a smaller step, or raise the output limit in AI Settings. + The reply hit the model\'s output limit before the action was complete, so nothing was changed. Ask for a smaller step. + Gemini could not write a valid tool call. This usually happens when one call carries a whole large file. Ask for the work in smaller steps. + Gemini returned an empty reply. Try again. + Gemini returned an empty reply (%1$s). Try again, or rephrase the request. + [Reply cut off at the model\'s output limit. Say \"continue\" for the rest.] Could not reach Gemini. Check your internet connection and try again. The Gemini request failed. The Gemini request failed. %1$s diff --git a/plugins/AI-Agent-Gemini/src/test/kotlin/com/itsaky/androidide/plugins/aiagentgemini/backend/GeminiBackendTest.kt b/plugins/AI-Agent-Gemini/src/test/kotlin/com/itsaky/androidide/plugins/aiagentgemini/backend/GeminiBackendTest.kt index e71b6310..c9aa9b7c 100644 --- a/plugins/AI-Agent-Gemini/src/test/kotlin/com/itsaky/androidide/plugins/aiagentgemini/backend/GeminiBackendTest.kt +++ b/plugins/AI-Agent-Gemini/src/test/kotlin/com/itsaky/androidide/plugins/aiagentgemini/backend/GeminiBackendTest.kt @@ -12,7 +12,13 @@ class GeminiBackendTest { @Before fun setup() { - backend = GeminiBackend(mockk(relaxed = true)) + backend = GeminiBackend(mockk(relaxed = true)) { null } + } + + @Test + fun givenConfigNotYetLoaded_whenAskedForItsPrompt_thenItReturnsNullInsteadOfBlocking() { + // Null is the contract's "no prompt of my own": ai-core then sends its default prompt. + assertNull(backend.getSystemPrompt(SystemPromptRequest(emptyList(), null, "app/Main.kt"))) } @Test @@ -42,7 +48,6 @@ class GeminiBackendTest { ChatMessage(ChatMessage.Role.USER, "Tool add_dependency: ok"), ), prompt = "Tool sync_project: ok", - config = LlmConfig("gemini"), ) assertEquals(1, contents.length()) @@ -62,14 +67,93 @@ class GeminiBackendTest { ChatMessage(ChatMessage.Role.ASSISTANT, "hi"), ), prompt = "how are you?", - config = LlmConfig("gemini").apply { systemPrompt = "be brief" }, ) val roles = (0 until contents.length()).map { contents.getJSONObject(it).getString("role") } - assertEquals(listOf("user", "model", "user", "model", "user"), roles) + assertEquals(listOf("user", "model", "user"), roles) assertEquals( - "be brief", + "hello", contents.getJSONObject(0).getJSONArray("parts").getJSONObject(0).getString("text"), ) } + + @Test + fun givenASystemPrompt_whenBuildingTheRequest_thenItIsSentAsSystemInstruction() { + // As a user turn it carried no more weight than text read out of a file (ADFA-6223). + val config = LlmConfig("gemini").apply { systemPrompt = "be brief" } + + val body = backend.buildRequestJson(backend.buildContents(emptyList(), "hi"), config) + + assertEquals( + "be brief", + body.getJSONObject("systemInstruction") + .getJSONArray("parts").getJSONObject(0).getString("text"), + ) + } + + @Test + fun givenASystemPrompt_whenBuildingTheRequest_thenTheCallersContentsAreLeftUntouched() { + // The caller keeps and reuses the array; the old shape prepended the prompt and an + // "Understood." turn the model never produced, and both landed in the next request too. + val config = LlmConfig("gemini").apply { systemPrompt = "be brief" } + val contents = backend.buildContents(emptyList(), "hi") + + assertEquals(1, contents.length()) + backend.buildRequestJson(contents, config) + + assertEquals(1, contents.length()) + val turn = contents.getJSONObject(0) + assertEquals("user", turn.getString("role")) + assertEquals("hi", turn.getJSONArray("parts").getJSONObject(0).getString("text")) + } + + @Test + fun givenNoSystemPrompt_whenBuildingTheRequest_thenTheFieldIsLeftOff() { + // An empty systemInstruction is a 400 from the API, so a blank prompt must omit the field. + val blank = backend.buildRequestJson( + backend.buildContents(emptyList(), "hi"), + LlmConfig("gemini").apply { systemPrompt = " " }, + ) + val absent = backend.buildRequestJson(backend.buildContents(emptyList(), "hi"), LlmConfig("gemini")) + + assertFalse(blank.has("systemInstruction")) + assertFalse(absent.has("systemInstruction")) + } + + @Test + fun givenARequiredDeclaredTool_whenBuildingTheRequest_thenGeminiIsMadeToCallOnlyIt() { + // Left to itself the model approved removed APIs without searching (ADFA-6223). + val config = LlmConfig("gemini").apply { extraParams = mapOf("required_tool" to "web_search") } + + val body = backend.buildRequestJson(backend.buildContents(emptyList(), "review"), config, SEARCH_TOOLS) + + val calling = body.getJSONObject("toolConfig").getJSONObject("functionCallingConfig") + assertEquals("ANY", calling.getString("mode")) + assertEquals("web_search", calling.getJSONArray("allowedFunctionNames").getString(0)) + assertEquals(1, calling.getJSONArray("allowedFunctionNames").length()) + } + + @Test + fun givenARequiredToolThatIsNotDeclared_whenBuildingTheRequest_thenNoToolConfigIsSent() { + // Gemini refuses the whole request over an allowed name it was not given. + val config = LlmConfig("gemini").apply { extraParams = mapOf("required_tool" to "fetch_url") } + + val body = backend.buildRequestJson(backend.buildContents(emptyList(), "review"), config, SEARCH_TOOLS) + + assertFalse(body.has("toolConfig")) + } + + @Test + fun givenNoRequiredTool_whenBuildingTheRequest_thenTheModelChoosesFreely() { + val body = backend.buildRequestJson(backend.buildContents(emptyList(), "hi"), LlmConfig("gemini"), SEARCH_TOOLS) + + assertFalse(body.has("toolConfig")) + } + + private companion object { + val SEARCH_TOOLS = listOf( + ToolDefinition("web_search", "Search the web", emptyMap()), + ToolDefinition("respond", "Reply", emptyMap()), + ) + } } diff --git a/plugins/AI-Agent-Gemini/src/test/kotlin/com/itsaky/androidide/plugins/aiagentgemini/backend/GeminiWebSearchTest.kt b/plugins/AI-Agent-Gemini/src/test/kotlin/com/itsaky/androidide/plugins/aiagentgemini/backend/GeminiWebSearchTest.kt new file mode 100644 index 00000000..ee760a6b --- /dev/null +++ b/plugins/AI-Agent-Gemini/src/test/kotlin/com/itsaky/androidide/plugins/aiagentgemini/backend/GeminiWebSearchTest.kt @@ -0,0 +1,108 @@ +package com.itsaky.androidide.plugins.aiagentgemini.backend + +import com.itsaky.androidide.plugins.services.LlmInferenceService.LlmConfig +import org.json.JSONObject +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +/** Unit tests for [GeminiWebSearch], the Google Search grounding behind ai-core's web_search tool. */ +class GeminiWebSearchTest { + + @Test + fun givenTheWebSearchExtraParam_whenChecking_thenASearchIsRequested() { + val config = LlmConfig("gemini").apply { extraParams = mapOf("web_search" to true) } + + assertTrue(GeminiWebSearch.isRequested(config)) + } + + @Test + fun givenOnlyTheGrammarExtraParam_whenChecking_thenNoSearchIsRequested() { + val config = LlmConfig("gemini").apply { extraParams = mapOf("grammar" to "root ::= x") } + + assertFalse(GeminiWebSearch.isRequested(config)) + assertFalse(GeminiWebSearch.isRequested(LlmConfig("gemini"))) + } + + @Test + fun givenARequestBody_whenDeclaringSearch_thenGoogleSearchIsTheOnlyTool() { + // Alone, because Gemini 2.x refuses Google Search beside function declarations. + val body = GeminiWebSearch.declareSearch(JSONObject().put("contents", "x")) + + val tools = body.getJSONArray("tools") + assertEquals(1, tools.length()) + assertEquals(listOf("google_search"), tools.getJSONObject(0).keys().asSequence().toList()) + } + + @Test + fun givenGroundingChunks_whenAddingSources_thenEachWebSourceIsListedOnce() { + val response = JSONObject( + """ + {"candidates":[{"groundingMetadata":{"groundingChunks":[ + {"web":{"uri":"https://a.example/1","title":"a.example"}}, + {"web":{"uri":"https://a.example/1","title":"a.example"}}, + {"web":{"uri":"https://b.example/2"}} + ]}}]} + """.trimIndent() + ) + + val text = GeminiWebSearch.withSources("Kotlin 2.3 is current.", response) + + assertEquals( + "Kotlin 2.3 is current.\n\nSources:\n- a.example: https://a.example/1\n- https://b.example/2", + text, + ) + } + + @Test + fun givenNoGroundingMetadata_whenAddingSources_thenTheTextIsUnchanged() { + val response = JSONObject("""{"candidates":[{"content":{"parts":[{"text":"hi"}]}}]}""") + + assertEquals("hi", GeminiWebSearch.withSources("hi", response)) + } + + @Test + fun givenResolvedLinks_whenAddingSources_thenEachIsListedByItsTarget() { + val response = JSONObject( + """ + {"candidates":[{"groundingMetadata":{"groundingChunks":[ + {"web":{"uri":"https://redirect.example/r1","title":"firebase.google.com"}}, + {"web":{"uri":"https://redirect.example/r2","title":"developer.android.com"}} + ]}}]} + """.trimIndent() + ) + val resolved = mapOf("https://redirect.example/r1" to "https://firebase.google.com/docs/ai-logic") + + val text = GeminiWebSearch.withSources("Found.", response, resolved) + + assertEquals( + "Found.\n\nSources:\n- firebase.google.com: https://firebase.google.com/docs/ai-logic\n" + + "- developer.android.com: https://redirect.example/r2", + text, + ) + assertEquals( + listOf("https://redirect.example/r1", "https://redirect.example/r2"), + GeminiWebSearch.sourceUris(response), + ) + } + + @Test + fun givenAReportTheCapCutShort_whenAddingSources_thenItIsMarkedIncompleteBeforeTheSources() { + val response = JSONObject( + """{"candidates":[{"finishReason":"MAX_TOKENS","groundingMetadata":{"groundingChunks":[""" + + """{"web":{"uri":"https://firebase.google.com/support/release-notes/android","title":"Notes"}}]}}]}""", + ) + + val text = GeminiWebSearch.withSources("The latest BoM is **v", response) + + assertTrue(text.startsWith("The latest BoM is **v\n\n${GeminiWebSearch.CUT_OFF_NOTE}\n\nSources:")) + } + + @Test + fun givenAReportThatEndedOnItsOwn_whenAddingSources_thenNoCutOffIsClaimed() { + val response = JSONObject("""{"candidates":[{"finishReason":"STOP"}]}""") + + assertEquals("BoM 34.3.0.", GeminiWebSearch.withSources("BoM 34.3.0.", response)) + } +} diff --git a/plugins/AI-Agent-Gemini/src/test/kotlin/com/itsaky/androidide/plugins/aiagentgemini/backend/GroundingRedirectTest.kt b/plugins/AI-Agent-Gemini/src/test/kotlin/com/itsaky/androidide/plugins/aiagentgemini/backend/GroundingRedirectTest.kt new file mode 100644 index 00000000..5fc6ec00 --- /dev/null +++ b/plugins/AI-Agent-Gemini/src/test/kotlin/com/itsaky/androidide/plugins/aiagentgemini/backend/GroundingRedirectTest.kt @@ -0,0 +1,100 @@ +package com.itsaky.androidide.plugins.aiagentgemini.backend + +import java.io.IOException +import java.net.HttpURLConnection +import java.net.URL +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Test + +/** Unit tests for [GroundingRedirect], which cites a grounding source by the page it points to. */ +class GroundingRedirectTest { + + /** Answers with [code] and [location], recording how it was asked. */ + private class FakeConnection( + url: URL, + private val code: Int, + private val location: String?, + private val failure: Exception? = null, + ) : HttpURLConnection(url) { + var disconnected = false + + override fun getResponseCode(): Int = failure?.let { throw it } ?: code + override fun getHeaderField(name: String): String? = + if (name.equals("Location", ignoreCase = true)) location else null + + override fun connect() {} + override fun disconnect() { + disconnected = true + } + + override fun usingProxy() = false + } + + private val source = "https://redirect.example/grounding/abc" + + @Test + fun givenARedirect_whenResolving_thenItsTargetIsReturnedWithoutFollowingIt() { + var opened: FakeConnection? = null + + val target = GroundingRedirect.target(source) { url -> + FakeConnection(url, 302, "https://firebase.google.com/docs/ai-logic").also { opened = it } + } + + assertEquals("https://firebase.google.com/docs/ai-logic", target) + assertEquals("HEAD", opened!!.requestMethod) + assertFalse(opened!!.instanceFollowRedirects) + assertEquals(true, opened!!.disconnected) + } + + @Test + fun givenARelativeLocation_whenResolving_thenItIsMadeAbsolute() { + val target = GroundingRedirect.target(source) { url -> FakeConnection(url, 301, "/docs/page") } + + assertEquals("https://redirect.example/docs/page", target) + } + + @Test + fun givenNoRedirect_whenResolving_thenTheLinkIsKept() { + val target = GroundingRedirect.target(source) { url -> FakeConnection(url, 200, null) } + + assertEquals(source, target) + } + + @Test + fun givenAFailedRequest_whenResolving_thenTheLinkIsKept() { + val target = GroundingRedirect.target(source) { url -> + FakeConnection(url, 0, null, IOException("timeout")) + } + + assertEquals(source, target) + } + + @Test + fun givenAMalformedLink_whenResolving_thenItIsKeptWithoutARequest() { + var opened = false + + val target = GroundingRedirect.target("not a url") { url -> + opened = true + FakeConnection(url, 302, "https://x.example") + } + + assertEquals("not a url", target) + assertFalse(opened) + } + + @Test + fun givenAnUncheckedFailure_whenResolving_thenTheLinkIsKept() { + val target = GroundingRedirect.target(source) { url -> + FakeConnection(url, 0, null, IllegalStateException("already connected")) + } + + assertEquals(source, target) + } + + @Test + fun givenANonHttpLink_whenResolving_thenItIsKeptRatherThanThrowing() { + // The default opener's cast used to throw ClassCastException here and fail the whole search. + assertEquals("file:///tmp/source", GroundingRedirect.target("file:///tmp/source")) + } +} diff --git a/plugins/AI-Agent-Gemini/src/test/kotlin/com/itsaky/androidide/plugins/aiagentgemini/errors/GeminiCredentialProblemTest.kt b/plugins/AI-Agent-Gemini/src/test/kotlin/com/itsaky/androidide/plugins/aiagentgemini/errors/GeminiCredentialProblemTest.kt index 08b72ba3..b30d02f8 100644 --- a/plugins/AI-Agent-Gemini/src/test/kotlin/com/itsaky/androidide/plugins/aiagentgemini/errors/GeminiCredentialProblemTest.kt +++ b/plugins/AI-Agent-Gemini/src/test/kotlin/com/itsaky/androidide/plugins/aiagentgemini/errors/GeminiCredentialProblemTest.kt @@ -31,6 +31,8 @@ class GeminiCredentialProblemTest { GeminiFailure.Unexpected(418, null), GeminiFailure.Unreachable, GeminiFailure.ReplyTruncated, + GeminiFailure.NoReply("MALFORMED_FUNCTION_CALL"), + GeminiFailure.NoReply(null), GeminiFailure.Failed("socket closed"), GeminiFailure.Failed(null), ) diff --git a/plugins/AI-Agent-Gemini/src/test/kotlin/com/itsaky/androidide/plugins/aiagentgemini/prompt/GeminiSystemPromptTest.kt b/plugins/AI-Agent-Gemini/src/test/kotlin/com/itsaky/androidide/plugins/aiagentgemini/prompt/GeminiSystemPromptTest.kt index b8bc91fa..0650b5d2 100644 --- a/plugins/AI-Agent-Gemini/src/test/kotlin/com/itsaky/androidide/plugins/aiagentgemini/prompt/GeminiSystemPromptTest.kt +++ b/plugins/AI-Agent-Gemini/src/test/kotlin/com/itsaky/androidide/plugins/aiagentgemini/prompt/GeminiSystemPromptTest.kt @@ -2,26 +2,38 @@ package com.itsaky.androidide.plugins.aiagentgemini.prompt import com.itsaky.androidide.plugins.services.LlmInferenceService.SystemPromptRequest import com.itsaky.androidide.plugins.services.LlmInferenceService.ToolDefinition +import com.itsaky.androidide.plugins.aiagentgemini.prompt.config.DirectoryPromptConfigSource.Companion.shippedConfig +import com.itsaky.androidide.plugins.aiagentgemini.prompt.config.DirectoryPromptConfigSource.Companion.shippedWith +import com.itsaky.androidide.plugins.aiagentgemini.prompt.config.GeminiPromptConfig +import org.junit.Assert.assertEquals import org.junit.Assert.assertFalse import org.junit.Assert.assertTrue import org.junit.Test /** - * Unit tests for [GeminiSystemPrompt]. Focus: the prompt teaches exactly one way to call a tool. - * Teaching both (ADFA-5410) is how a call ends up written as text that nothing runs. + * Unit tests for [GeminiSystemPrompt]. Focus: the prompt teaches exactly one way to call a tool; + * teaching both (ADFA-5410) is how a call ends up written as text that nothing runs. Also: its + * wording changes by editing `assets/prompts/` alone, and a typo is caught, by file. */ class GeminiSystemPromptTest { private companion object { + /** The rule priorities, highest first; `rules.yml` may use only these headings. */ + val PRIORITIES = listOf("CRITICAL", "IMPORTANT", "MANDATORY", "OPTIONAL") + const val SYNTAX = """{"tool":"TOOL_NAME","args":{"arg":"value"}}""" } - private fun prompt(toolCallSyntax: String?) = GeminiSystemPrompt.build( - SystemPromptRequest( - listOf(ToolDefinition("read_file", "Read a file", emptyMap())), - toolCallSyntax, - "app/src/main/java/com/example/MainActivity.kt", - ) + private val tools = listOf(ToolDefinition("read_file", "Read a file", emptyMap())) + + private fun prompt( + toolCallSyntax: String?, + tools: List = this.tools, + config: GeminiPromptConfig = shippedConfig, + examplePath: String = "app/src/main/java/com/example/MainActivity.kt", + ) = GeminiSystemPrompt.build( + SystemPromptRequest(tools, toolCallSyntax, examplePath), + config, ) @Test @@ -47,6 +59,40 @@ class GeminiSystemPromptTest { } } + @Test + fun givenEitherMode_whenBuilding_thenAnOffDomainRequestIsNeverDeclined() { + // A prompt whose stated goal was only building Android apps left a general question no + // legal path through it, and the model declined rather than answer (ADFA-6223). + listOf(SYNTAX, null).forEach { syntax -> + val prompt = prompt(syntax) + + assertTrue(prompt.contains("SCOPE:")) + assertTrue( + prompt.contains("Never decline a request on the grounds that it is not about Android") + ) + } + } + + @Test + fun givenEitherMode_whenBuilding_thenTheBuildWorkflowIsIntroducedConditionally() { + // Every step presumes an app-build task, so stated unconditionally it is the refusal above. + listOf(SYNTAX, null).forEach { syntax -> + val prompt = prompt(syntax) + + assertTrue(prompt.contains("WORKFLOW — follow these steps only when the user tells you to build")) + } + } + + @Test + fun givenAnyRequest_whenBuilding_thenOneToolCallPerReplyIsRequiredOnlyWhenAToolIsCalled() { + // Stated absolutely it contradicts the rule below that a question is answered in the reply + // itself, which is most of the traffic now. + val prompt = prompt(SYNTAX) + + assertTrue(prompt.contains("When you call a tool, emit ONE per reply")) + assertFalse(prompt.contains("Emit ONE tool call per reply")) + } + @Test fun givenEitherMode_whenBuilding_thenTheWorkflowDoesNotContradictTheRuleAgainstWalkingTheTree() { // WORKFLOW step 2 used to say "List files to understand the project structure", against a @@ -58,4 +104,124 @@ class GeminiSystemPromptTest { assertTrue(prompt.contains("Never walk the tree with repeated list_files calls")) } } + + @Test + fun givenSeveralTools_whenBuilding_thenNoLineIsIndented() { + // The Kotlin version interpolated the tool list into a raw string, which defeated + // trimIndent and sent 25 of 44 lines indented by eight spaces. + val many = tools + ToolDefinition("respond", "Answer the user", emptyMap()) + + listOf(SYNTAX, null).forEach { syntax -> + assertFalse(prompt(syntax, many).lines().any { it.startsWith(" ") }) + } + } + + @Test + fun givenNativeCalling_whenBuilding_thenOnlyTheNativeFormatIsSentAndNoTagLeaksThrough() { + val prompt = prompt(null) + + assertTrue(prompt.contains("TOOL CALL FORMAT — the tools above are declared to you")) + assertFalse(prompt.contains("{{")) + } + + @Test + fun givenTheExamplePath_whenBuilding_thenTheSearchExampleUsesItsFileStem() { + assertTrue(prompt(SYNTAX).contains("""{"query":"MainActivity"}""")) + } + + @Test + fun givenADotfileExamplePath_whenBuilding_thenTheSearchExampleUsesItsWholeName() { + assertTrue(prompt(SYNTAX, examplePath = "app/.gitignore").contains("""{"query":".gitignore"}""")) + } + + @Test + fun givenTheShippedRules_whenRead_thenThePrioritiesAreKnownAndInOrder() { + // A heading the model has not been taught has no weight, and a lower one first misleads it. + val headings = shippedConfig.rules.map { it.heading.template } + + assertTrue(headings.all { it in PRIORITIES }) + assertEquals(headings.sortedBy { PRIORITIES.indexOf(it) }, headings) + } + + @Test + fun givenEitherMode_whenBuilding_thenTheCriticalRulesComeFirst() { + listOf(SYNTAX, null).forEach { syntax -> + val prompt = prompt(syntax) + + assertTrue(prompt.indexOf("CRITICAL:") < prompt.indexOf("- When you call a tool, emit ONE")) + assertTrue(prompt.indexOf("- Never fabricate tool output") < prompt.indexOf("IMPORTANT:")) + } + } + + @Test + fun givenTheShippedFiles_whenChecked_thenThePromptRendersForEveryRequest() { + // A typo fails the render, so the shipped set must have none. + assertEquals(emptyList(), GeminiSystemPrompt.problems(shippedConfig)) + } + + @Test + fun givenARuleWithATypo_whenChecked_thenItIsReportedByItsFileAndPath() { + val config = shippedWith("rules.yml") { + it.replace("Never fabricate tool output.", "Never fabricate {{TOOL_LIST}} output.") + } + + assertEquals( + listOf("rules.yml: rules[0].items[1]: unknown name {{TOOL_LIST}}"), + GeminiSystemPrompt.problems(config), + ) + } + + @Test + fun givenATypoBehindTheTextProtocol_whenChecked_thenItIsStillReported() { + // The check renders under both protocols, so the one a run rarely takes is covered too. + val config = shippedWith("tools.yml") { it.replace("{{EXAMPLE_FILE_STEM}}", "{{EXAMPLE_STEM}}") } + + assertEquals( + listOf("tools.yml: tool_call_format.text.examples[2].call: unknown name {{EXAMPLE_STEM}}"), + GeminiSystemPrompt.problems(config), + ) + } + + @Test + fun givenANewRule_whenBuilding_thenItIsSentAmongTheRulesWithNoCodeChange() { + val config = shippedWith("rules.yml") { + it.replace(" - heading: IMPORTANT\n items:\n", " - heading: IMPORTANT\n items:\n - NEW RULE.\n") + } + + val prompt = prompt(SYNTAX, config = config) + + assertTrue(prompt.contains("IMPORTANT:\n- NEW RULE.\n- To locate a file")) + assertTrue(prompt.indexOf("NEW RULE.") < prompt.indexOf("TOOL CALL FORMAT")) + } + + @Test + fun givenANewWorkflowStep_whenBuilding_thenTheStepsAreRenumbered() { + val config = shippedWith("workflow.yml") { + it.replace(" - Understand the user's request\n", " - Understand the user's request\n - Ask if unsure\n") + } + + val prompt = prompt(SYNTAX, config = config) + + assertTrue(prompt.contains("1. Understand the user's request\n2. Ask if unsure\n3. Locate")) + assertTrue(prompt.contains("8. Report success and what was built")) + } + + @Test + fun givenANewIdentity_whenBuilding_thenTheToneChangesWithNoCodeChange() { + val config = shippedWith("agent.yml") { + it.replace(Regex("(?s)identity: >-\n.*?\n\n"), "identity: Eres el asistente de CodeOnTheGo.\n\n") + } + + assertTrue(prompt(null, config = config).startsWith("Eres el asistente de CodeOnTheGo.\n\nSCOPE:")) + } + + @Test + fun givenAReorderedLayout_whenBuilding_thenTheSectionsFollowIt() { + // The order the model reads things in is config too, not code. + val config = shippedWith("layout.yml") { + it.replace(" {{IDENTITY}}\n\n", " {{WORKFLOW_CLOSING}}\n {{IDENTITY}}\n\n") + } + + assertTrue(prompt(null, config = config).startsWith("Skip every one of those steps")) + } } diff --git a/plugins/AI-Agent-Gemini/src/test/kotlin/com/itsaky/androidide/plugins/aiagentgemini/prompt/config/DirectoryPromptConfigSource.kt b/plugins/AI-Agent-Gemini/src/test/kotlin/com/itsaky/androidide/plugins/aiagentgemini/prompt/config/DirectoryPromptConfigSource.kt new file mode 100644 index 00000000..ee084a55 --- /dev/null +++ b/plugins/AI-Agent-Gemini/src/test/kotlin/com/itsaky/androidide/plugins/aiagentgemini/prompt/config/DirectoryPromptConfigSource.kt @@ -0,0 +1,46 @@ +package com.itsaky.androidide.plugins.aiagentgemini.prompt.config + +import com.itsaky.androidide.plugins.ai.prompt.PromptConfigLoader +import com.itsaky.androidide.plugins.ai.prompt.PromptConfigSource +import java.io.File +import java.io.FileNotFoundException +import kotlinx.coroutines.runBlocking + +/** + * Reads config from a directory, so JVM tests render the exact files the `.cgp` ships. + * + * @param root the directory holding the config files. + * @param edits replaces one file's text before it is returned, as a device would see an edited file. + */ +class DirectoryPromptConfigSource( + private val root: File, + private val edits: Map String> = emptyMap(), +) : PromptConfigSource { + + override fun read(path: String): String { + val file = File(root, path) + if (!file.isFile) throw FileNotFoundException(path) + return edits[path]?.invoke(file.readText()) ?: file.readText() + } + + companion object { + /** The shipped config files; unit tests run with the module directory as working dir. */ + val SHIPPED_ROOT = File("src/main/assets/prompts") + + /** The shipped config, loaded once for every test that renders a prompt. */ + val shippedConfig: GeminiPromptConfig by lazy { load(DirectoryPromptConfigSource(SHIPPED_ROOT)) } + + /** + * Loads the shipped config with one file rewritten by [edit]. + * + * @param file the file to edit, e.g. `rules.yml`. + * @param edit rewrites that file's text. + * @return the config loaded from the edited files. + */ + fun shippedWith(file: String, edit: (String) -> String): GeminiPromptConfig = + load(DirectoryPromptConfigSource(SHIPPED_ROOT, mapOf(file to edit))) + + private fun load(source: PromptConfigSource): GeminiPromptConfig = + runBlocking { PromptConfigLoader.load(source, GeminiPromptConfigParser) } + } +} diff --git a/plugins/AI-Agent-Gemini/src/test/kotlin/com/itsaky/androidide/plugins/aiagentgemini/prompt/config/GeminiPromptConfigParserTest.kt b/plugins/AI-Agent-Gemini/src/test/kotlin/com/itsaky/androidide/plugins/aiagentgemini/prompt/config/GeminiPromptConfigParserTest.kt new file mode 100644 index 00000000..00456cb0 --- /dev/null +++ b/plugins/AI-Agent-Gemini/src/test/kotlin/com/itsaky/androidide/plugins/aiagentgemini/prompt/config/GeminiPromptConfigParserTest.kt @@ -0,0 +1,113 @@ +package com.itsaky.androidide.plugins.aiagentgemini.prompt.config + +import com.itsaky.androidide.plugins.ai.prompt.PromptConfigException +import com.itsaky.androidide.plugins.aiagentgemini.prompt.config.DirectoryPromptConfigSource.Companion.shippedConfig +import com.itsaky.androidide.plugins.aiagentgemini.prompt.config.DirectoryPromptConfigSource.Companion.shippedWith +import org.junit.Assert.assertEquals +import org.junit.Assert.assertThrows +import org.junit.Assert.assertTrue +import org.junit.Test + +/** + * Unit tests for [GeminiPromptConfigParser]: a mistake in a prompt file is refused naming that file + * and the key, rather than reaching the model as a prompt with a hole in it. + */ +class GeminiPromptConfigParserTest { + + @Test + fun givenTheShippedFiles_whenParsing_thenEveryTextIsLabelledWithItsOwnFileAndPath() { + assertEquals("agent.yml: identity", shippedConfig.identity.label) + assertEquals("scope.yml: scope.items[1]", shippedConfig.scope.items[1].label) + assertEquals("rules.yml: rules[0].items[2]", shippedConfig.rules[0].items[2].label) + assertEquals("workflow.yml: workflow.steps[1]", shippedConfig.workflow.steps[1].label) + assertEquals( + "tools.yml: tool_call_format.text.examples[2].call", + shippedConfig.toolCallFormat.text.examples[2].call.label, + ) + assertEquals("layout.yml: layout.system_prompt", shippedConfig.layout.systemPrompt.label) + } + + @Test + fun givenAFoldedScalar_whenParsing_thenItsLinesAreJoinedIntoOneSentence() { + // Source line wraps must not reach the model as newlines mid-sentence. + val rule = shippedConfig.rules[0].items[0].template + + assertTrue(rule.startsWith("When you call a tool, emit ONE per reply, then stop and wait.")) + assertTrue('\n' !in rule) + } + + @Test + fun givenALiteralBlock_whenParsing_thenItsLineBreaksAreKept() { + val native = shippedConfig.toolCallFormat.native.template + + assertEquals(2, native.lines().size) + } + + @Test + fun givenAMissingNestedKey_whenParsing_thenItIsNamedWithItsFileAndPath() { + assertRefused("tools.yml: tool_call_format.text.only_the_line_runs is missing", "tools.yml") { + it.replace(Regex("(?m)^ only_the_line_runs: .*\n"), "") + } + } + + @Test + fun givenAMissingTopLevelKey_whenParsing_thenItIsReportedAgainstTheEntryFile() { + // No file holds it, so the entry file, which decides what is read, is the one to fix. + assertRefused("agent.yml: scope is missing", "scope.yml") { "other: x\n" } + } + + @Test + fun givenAnExtraNestedKey_whenParsing_thenItIsRefusedAsUnknown() { + assertRefused("tools.yml: tools: unknown key tone; expected heading", "tools.yml") { + it.replace("tools:\n heading: AVAILABLE TOOLS", "tools:\n heading: AVAILABLE TOOLS\n tone: friendly") + } + } + + @Test + fun givenAnExampleWithoutItsCall_whenParsing_thenTheExampleIsNamed() { + assertRefused("tools.yml: tool_call_format.text.examples[0].call is missing", "tools.yml") { + it.replace(Regex("(?m)^ call: '\\{\"tool\":\"respond\".*\n"), "") + } + } + + @Test + fun givenAnUnquotedNumber_whenParsing_thenItIsRefusedAsNotText() { + assertRefused("scope.yml: scope.heading expected text; quote it", "scope.yml") { + it.replace(" heading: SCOPE", " heading: 42") + } + } + + @Test + fun givenAWorkflowWithNoSteps_whenParsing_thenItIsRefused() { + assertRefused("workflow.yml: workflow.steps is empty", "workflow.yml") { + it.replace(Regex("(?s) steps:\n.*?(?= closing:)"), " steps: []\n") + } + } + + @Test + fun givenANewerSchemaVersion_whenParsing_thenItIsRefusedNamingBoth() { + assertRefused("agent.yml: schema_version is 2, but this Gemini plugin reads 1", "agent.yml") { + it.replace("schema_version: 1", "schema_version: 2") + } + } + + @Test + fun givenBrokenYaml_whenParsing_thenTheFileNameAndPositionAreReported() { + val error = refused("rules.yml") { "rules: [unclosed" } + + assertTrue(error.message!!.startsWith("rules.yml: ")) + assertTrue(error.message!!.contains("line")) + } + + @Test + fun givenADuplicateKeyInOneFile_whenParsing_thenItIsRefused() { + // YAML would otherwise keep the second silently, and an edit to the first would do nothing. + refused("agent.yml") { "$it\nidentity: again\n" } + } + + private fun refused(file: String, edit: (String) -> String): PromptConfigException = + assertThrows(PromptConfigException::class.java) { shippedWith(file, edit) } + + private fun assertRefused(message: String, file: String, edit: (String) -> String) = + assertEquals(message, refused(file, edit).message) +} diff --git a/plugins/AI-Agent-Gemini/src/test/kotlin/com/itsaky/androidide/plugins/aiagentgemini/prompt/config/ShippedPromptFilesTest.kt b/plugins/AI-Agent-Gemini/src/test/kotlin/com/itsaky/androidide/plugins/aiagentgemini/prompt/config/ShippedPromptFilesTest.kt new file mode 100644 index 00000000..f01ee53c --- /dev/null +++ b/plugins/AI-Agent-Gemini/src/test/kotlin/com/itsaky/androidide/plugins/aiagentgemini/prompt/config/ShippedPromptFilesTest.kt @@ -0,0 +1,55 @@ +package com.itsaky.androidide.plugins.aiagentgemini.prompt.config + +import com.itsaky.androidide.plugins.ai.prompt.PromptConfigLoader +import com.itsaky.androidide.plugins.ai.prompt.PromptConfigSource +import com.itsaky.androidide.plugins.aiagentgemini.prompt.config.DirectoryPromptConfigSource.Companion.SHIPPED_ROOT +import java.io.File +import kotlinx.coroutines.runBlocking +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +/** The shipped `assets/prompts/` files: every one included once, in order, with no malformed tag. */ +class ShippedPromptFilesTest { + + /** The shipped files, by name. */ + private val shipped: Map = + SHIPPED_ROOT.listFiles { f -> f.extension == "yml" }!!.associate { it.name to it.readText() } + + @Test + fun givenTheShippedEntryFile_whenLoading_thenItAndEveryIncludeAreReadInOrder() { + val paths = mutableListOf() + val source = PromptConfigSource { path -> paths += path; File(SHIPPED_ROOT, path).readText() } + + runBlocking { PromptConfigLoader.load(source, GeminiPromptConfigParser) } + + assertEquals( + listOf("agent.yml", "scope.yml", "rules.yml", "workflow.yml", "tools.yml", "layout.yml"), + paths, + ) + } + + @Test + fun givenEveryShippedFile_whenListed_thenEachIsIncludedExactlyOnce() { + // A .yml nobody includes is dead wording that looks live to whoever edits it. + val entry = shipped.getValue("agent.yml") + val included = Regex("(?m)^ - (\\S+\\.yml)$").findAll(entry).map { it.groupValues[1] } + + assertEquals(shipped.keys - "agent.yml", included.toSet()) + } + + @Test + fun givenTheShippedFiles_whenScanned_thenNoTagIsMalformed() { + // A `{{name}}` or `{{ #X}}` typo would reach the model verbatim, since it is no tag. + assertTrue(shipped.isNotEmpty()) + for ((name, text) in shipped) { + assertFalse("$name has a malformed tag", MALFORMED_TAG.containsMatchIn(text)) + } + } + + private companion object { + /** A `{{` that opens none of `{{NAME}}`, `{{#NAME}}`, `{{^NAME}}` or `{{/NAME}}`. */ + val MALFORMED_TAG = Regex("""\{\{(?![#^/]?[A-Z])""") + } +} diff --git a/plugins/AI-Agent-Local/README.md b/plugins/AI-Agent-Local/README.md index fb08f1cc..7ac95533 100644 --- a/plugins/AI-Agent-Local/README.md +++ b/plugins/AI-Agent-Local/README.md @@ -48,6 +48,44 @@ via CodeOnTheGo's Plugin Manager, then restart the IDE. The model file itself is chosen in **AI Core → Agent settings**; this backend reads that setting at request time. +## System prompt config + +With **Use simple local prompt** on, this backend asks ai-core to send a short prompt +written for 1–3B on-device models; it lives in `src/main/assets/prompts/`, one YAML +file per concern, apart from the code that sends it. Changing the tone, adding a +rule or translating the prompt is an edit to those files alone. ai-core appends its +own IDE CONTEXT block after the rendered prompt. With the setting off, +`getSystemPrompt` returns null and ai-core's own prompt is sent. + +The files are loaded, validated and cached once, when the plugin is activated. +`getSystemPrompt` renders `layout.yml` from that cache for each request, since the +tool list and the example path vary per run; it never waits. Until the config has +loaded, or if it cannot render, it returns null and ai-core sends its default prompt. + +| File | Keys | What it is | +|---|---|---| +| `agent.yml` | `schema_version`, `identity`, `include` | The entry point: the version (`1`; another is refused rather than misread), who the agent is, and the files below. | +| `rules.yml` | `rules` | Rule groups, each a `heading` and its `items`; today one `Rules` group. **Adding a rule is adding an item.** | +| `tools.yml` | `tools`, `tool_call_format` | What introduces the tool list, and `tool_call_format.text`: the envelope and its examples, each a `purpose` and a `call`. A small model calls through the text protocol only, so there is no native format; when ai-core parses no text calls, none of it is sent. | +| `layout.yml` | `layout.system_prompt` | Where each text goes. | + +The structure, the names texts are rendered under and the checks are AI-Agent-Gemini's +and AI-Agent-OpenAI's (see Gemini's README). The request's values are `TOOLS` (each +with `NAME`, `DESCRIPTION`, inserted verbatim), `TOOL_CALL_SYNTAX`, +`EXAMPLE_FILE_PATH`, `EXAMPLE_FILE_NAME` (the bare file name, which `read_file` and +`open_file` accept) and `EXAMPLE_FILE_STEM`. + +Rendering is strict: an unknown name throws, naming the text it was in, where the +file-per-section design this replaced dropped the file silently. Activation renders +the prompt for requests that open and close every section and logs any failure, and +`LocalSystemPromptTest` fails on one in the shipped files. A new key needs +`LocalPromptConfig` and its parser; a new name needs `LocalPromptVariables`. + +The engine and the YAML plumbing (`PromptTemplateEngine`, `PromptConfigLoader`, +`PromptConfigStore`, `PromptConfigObject`, ...) are the IDE's, in `plugin-api.jar`'s +`com.itsaky.androidide.plugins.ai.prompt`, shared with ai-core and the other backends. +Only `LocalPromptConfig`, its mapping in `LocalPromptConfigParser`, and `sharedPromptConfig` are this plugin's own. + ## Key classes Every source file sits in a package named for its layer; nothing is loose at the @@ -60,7 +98,9 @@ root of `com/itsaky/androidide/plugins/aiagentlocal/`. classification and its user-facing wording - `preferences/LocalLlmPreferences.kt` — this plugin's settings store, plus the one-time adoption of settings written under earlier plugin ids -- `prompt/LocalSystemPrompt.kt` — the system prompt small on-device models need +- `prompt/LocalSystemPrompt.kt` — renders `layout.yml` from `LocalPromptVariables`; + `prompt/config/` maps `assets/prompts/` onto this plugin's config type, which the + IDE's `ai.prompt` package loads, validates, caches and renders - `feedback/UserFeedback.kt` — throttled Toasts, and the actionable exceptions - `format/ByteSize.kt` — binary-unit rendering of RAM figures - `logging/` — `LOG_PREFIX` (`AiAgentLocal`), prefixing every logcat tag this plugin writes diff --git a/plugins/AI-Agent-Local/build.gradle.kts b/plugins/AI-Agent-Local/build.gradle.kts index 3096aafc..f7882f8c 100644 --- a/plugins/AI-Agent-Local/build.gradle.kts +++ b/plugins/AI-Agent-Local/build.gradle.kts @@ -77,7 +77,10 @@ dependencies { implementation("org.jetbrains.kotlin:kotlin-stdlib:2.3.21") implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.7.3") + testImplementation(files("../../libs/plugin-api.jar")) + // plugin-api's prompt loader parses YAML with the host's copy; JVM tests need their own, same version + testImplementation("org.snakeyaml:snakeyaml-engine:2.10") testImplementation("junit:junit:4.13.2") testImplementation("io.mockk:mockk:1.13.8") // LiveData's postValue needs the arch-core executor swapped for a synchronous one; the diff --git a/plugins/AI-Agent-Local/src/main/assets/prompts/agent.yml b/plugins/AI-Agent-Local/src/main/assets/prompts/agent.yml new file mode 100644 index 00000000..7db9cd66 --- /dev/null +++ b/plugins/AI-Agent-Local/src/main/assets/prompts/agent.yml @@ -0,0 +1,21 @@ +# The local model's system prompt: the wording this backend asks ai-core to send when the simple +# prompt is on, apart from the code that sends it. Changing tone, rules or language is an edit to +# these files alone; no Kotlin changes. Written for a small on-device model, so it is short. +# +# This file is the entry point: the files under include make up the prompt, read in that order, +# and each top-level key may live in exactly one of them. Every text is a template over the +# request's values, e.g. {{EXAMPLE_FILE_NAME}}; see README.md. The plugin validates them on +# activation, and LocalSystemPromptTest fails on a mistake in the shipped files. ai-core appends +# its own IDE CONTEXT block after the rendered prompt. + +schema_version: 1 + +# Who the agent is; the first thing the model reads. +identity: >- + You are a coding assistant inside CodeOnTheGo. You answer anything the user asks, not only + Android questions. + +include: + - rules.yml + - tools.yml + - layout.yml diff --git a/plugins/AI-Agent-Local/src/main/assets/prompts/layout.yml b/plugins/AI-Agent-Local/src/main/assets/prompts/layout.yml new file mode 100644 index 00000000..0fdfc7bb --- /dev/null +++ b/plugins/AI-Agent-Local/src/main/assets/prompts/layout.yml @@ -0,0 +1,32 @@ +# Where each text from the other files goes, by the name it is rendered under (see README.md). +# A line holding only a section tag (#, ^ or /) vanishes, so tags can sit on their own lines. + +layout: + system_prompt: |- + {{IDENTITY}} + + {{#RULES}} + {{^FIRST}} + + {{/FIRST}} + {{HEADING}}: + {{#ITEMS}} + - {{TEXT}} + {{/ITEMS}} + {{/RULES}} + + {{TOOLS_HEADING}}: + {{#TOOLS}} + - {{NAME}}: {{DESCRIPTION}} + {{/TOOLS}} + {{#TOOL_CALL_SYNTAX}} + + {{TOOL_CALL_FORMAT_TEXT_INSTRUCTION}} + {{TOOL_CALL_SYNTAX}} + + {{TOOL_CALL_FORMAT_TEXT_EXAMPLES_HEADING}}: + {{#TOOL_CALL_FORMAT_TEXT_EXAMPLES}} + {{PURPOSE}}: + {{CALL}} + {{/TOOL_CALL_FORMAT_TEXT_EXAMPLES}} + {{/TOOL_CALL_SYNTAX}} diff --git a/plugins/AI-Agent-Local/src/main/assets/prompts/rules.yml b/plugins/AI-Agent-Local/src/main/assets/prompts/rules.yml new file mode 100644 index 00000000..881488ac --- /dev/null +++ b/plugins/AI-Agent-Local/src/main/assets/prompts/rules.yml @@ -0,0 +1,38 @@ +# What the agent must and must not do. Adding a rule is adding an item. Each group renders as +# "HEADING:" with its items as "- " lines; more groups are separated by a blank line. + +rules: + - heading: Rules + items: + - 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 refuse a question because it is not about Android or not about this project. Answer + it with "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. + - >- + read_file and open_file accept a bare file name (the project is searched for it). Never + invent deep paths. + - >- + To change a file, use edit_file, not update_file. Call read_file FIRST, then copy the text + to replace into "old_string" EXACTLY as it appears in that output (same spelling, same + indentation). It must appear only once — include the line above or below if it doesn't. + - >- + "old_string" is the text that is in the file NOW; "new_string" is what it should become. + They must differ. To rename x to y: old_string has x, new_string has y. + - >- + Never put a real line break inside an argument value: write it as \n. Keep + old_string/new_string to a few lines; make several small edits rather than one big one. + - >- + edit_file needs a real path, not a bare name, and never a path you invented. If you don't + know it, call search_project with the file name FIRST and use the path it returns — don't + guess the folders, and don't guess the extension (.kt vs .java). + - >- + To rename something everywhere in a file, make ONE edit_file call with old_string set to + just the old name and "replace_all":"true". diff --git a/plugins/AI-Agent-Local/src/main/assets/prompts/tools.yml b/plugins/AI-Agent-Local/src/main/assets/prompts/tools.yml new file mode 100644 index 00000000..e44632f0 --- /dev/null +++ b/plugins/AI-Agent-Local/src/main/assets/prompts/tools.yml @@ -0,0 +1,27 @@ +# How the tool list is introduced, and how to call a tool. A small model calls through the text +# protocol only; when ai-core parses no text calls, none of tool_call_format is sent. + +tools: + heading: Tools + +tool_call_format: + text: + instruction: >- + TOOL CALL FORMAT — emit a single line in EXACTLY this format and nothing after it: + examples_heading: Examples (pick the tool that matches; copy the FORMAT, not the values) + # Each renders as "PURPOSE:" followed by the call on its own line. + examples: + - purpose: Greeting / question you can answer -> respond + call: '{"tool":"respond","args":{"message":"Hi! What would you like to build?"}}' + - purpose: Open a file (a bare name is fine here) -> open_file + call: '{"tool":"open_file","args":{"file_path":"{{EXAMPLE_FILE_NAME}}"}}' + - purpose: Change one line of a file -> edit_file + call: '{"tool":"edit_file","args":{"file_path":"{{EXAMPLE_FILE_PATH}}","old_string":"setTitle(\"Old\")","new_string":"setTitle(\"New\")"}}' + - purpose: Change two lines (note the \n, never a real line break) -> edit_file + call: '{"tool":"edit_file","args":{"file_path":"{{EXAMPLE_FILE_PATH}}","old_string":"a = 1\nb = 2","new_string":"a = 10\nb = 20"}}' + - purpose: >- + Rename every use of one name in a file -> ONE edit_file with replace_all (NOT one call + per line) + call: '{"tool":"edit_file","args":{"file_path":"{{EXAMPLE_FILE_PATH}}","old_string":"oldName","new_string":"newName","replace_all":"true"}}' + - purpose: Find where a file actually lives before editing it -> search_project + call: '{"tool":"search_project","args":{"query":"{{EXAMPLE_FILE_STEM}}"}}' diff --git a/plugins/AI-Agent-Local/src/main/kotlin/com/itsaky/androidide/plugins/aiagentlocal/backend/LocalLlmBackend.kt b/plugins/AI-Agent-Local/src/main/kotlin/com/itsaky/androidide/plugins/aiagentlocal/backend/LocalLlmBackend.kt index 644b3ea2..dd7a5e86 100644 --- a/plugins/AI-Agent-Local/src/main/kotlin/com/itsaky/androidide/plugins/aiagentlocal/backend/LocalLlmBackend.kt +++ b/plugins/AI-Agent-Local/src/main/kotlin/com/itsaky/androidide/plugins/aiagentlocal/backend/LocalLlmBackend.kt @@ -25,6 +25,7 @@ import com.itsaky.androidide.plugins.aiagentlocal.model.PlatformModelSourceWatch import com.itsaky.androidide.plugins.aiagentlocal.model.SourceReachability import com.itsaky.androidide.plugins.aiagentlocal.preferences.LocalLlmPreferences import com.itsaky.androidide.plugins.aiagentlocal.prompt.LocalSystemPrompt +import com.itsaky.androidide.plugins.aiagentlocal.prompt.config.LocalPromptConfig import com.itsaky.androidide.plugins.services.LlmInferenceService import com.itsaky.androidide.plugins.services.LlmInferenceService.* import com.itsaky.androidide.plugins.services.SharedServices @@ -50,9 +51,12 @@ import kotlinx.coroutines.withTimeoutOrNull /** * Local LLM backend using llama-impl for on-device inference. * Wraps llama-impl APIs and implements LlmBackend interface. + * + * @param promptConfig the loaded prompt config, or null while it loads; must return without blocking */ class LocalLlmBackend( private val context: PluginContext, + private val promptConfig: () -> LocalPromptConfig?, private val modelSourceOverride: NativeModelSource? = null, private val engineOverride: ModelResidencyEngine? = null, private val watcherOverride: ModelSourceWatcher? = null, @@ -66,6 +70,16 @@ class LocalLlmBackend( */ const val EXTRA_PARAM_GRAMMAR = "grammar" + /** + * `extraParams` key ai-core's `web_search` tool sets to ask for an answer from a web + * search, which an on-device model cannot make; see [generate]. + */ + const val EXTRA_PARAM_WEB_SEARCH = "web_search" + + /** What the agent is told when it asks for a search; it can still read a page with fetch_url. */ + private const val WEB_SEARCH_UNSUPPORTED = + "Web search is not available with the on-device model. Use fetch_url to read a page instead." + /** * Belt-and-braces guard: `<|im_end|>` is an EOG control token, so the native loop @@ -223,9 +237,22 @@ class LocalLlmBackend( * * Null when the user turned the short prompt off, which is what hands them back the caller's * own full tool-calling prompt — a larger model can follow it, and this backend can run one. - */ - override fun getSystemPrompt(request: SystemPromptRequest): String? = - if (LocalLlmPreferences.useSimplePrompt(context)) LocalSystemPrompt.build(request) else null + * Also null until the templates are loaded; never blocks, since the thread is ai-core's. + */ + override fun getSystemPrompt(request: SystemPromptRequest): String? { + if (!LocalLlmPreferences.useSimplePrompt(context)) return null + val config = promptConfig() + if (config == null) { + context.logger.warn("LocalLlmBackend: prompt config not loaded; ai-core default used") + return null + } + return try { + LocalSystemPrompt.build(request, config) + } catch (e: IllegalArgumentException) { + context.logger.error("LocalLlmBackend: prompt did not render; ai-core default used", e) + null + } + } /** * Near-greedy: tool arguments must be copied out of earlier tool output verbatim, and a small @@ -685,6 +712,10 @@ class LocalLlmBackend( override fun generate(prompt: String, config: LlmConfig): CompletableFuture { context.logger.info("LocalLlmBackend.generate() called with prompt: ${prompt.take(50)}...") + // Refused, not answered: a search reply made up from the model's memory reads as found fact. + if (config.extraParams?.get(EXTRA_PARAM_WEB_SEARCH) == true) { + return CompletableFuture.completedFuture(LlmResponse.failure(WEB_SEARCH_UNSUPPORTED)) + } return runGeneration(buildPrompt(config.systemPrompt, prompt), config) } diff --git a/plugins/AI-Agent-Local/src/main/kotlin/com/itsaky/androidide/plugins/aiagentlocal/plugin/LocalLlmPlugin.kt b/plugins/AI-Agent-Local/src/main/kotlin/com/itsaky/androidide/plugins/aiagentlocal/plugin/LocalLlmPlugin.kt index f7409562..8b6a29e9 100644 --- a/plugins/AI-Agent-Local/src/main/kotlin/com/itsaky/androidide/plugins/aiagentlocal/plugin/LocalLlmPlugin.kt +++ b/plugins/AI-Agent-Local/src/main/kotlin/com/itsaky/androidide/plugins/aiagentlocal/plugin/LocalLlmPlugin.kt @@ -3,13 +3,23 @@ package com.itsaky.androidide.plugins.aiagentlocal.plugin import com.itsaky.androidide.plugins.IPlugin import com.itsaky.androidide.plugins.PluginContext import com.itsaky.androidide.plugins.PluginLifecycleListener +import com.itsaky.androidide.plugins.ai.prompt.AssetPromptConfigSource import com.itsaky.androidide.plugins.aiagentlocal.backend.LocalLlmBackend import com.itsaky.androidide.plugins.aiagentlocal.preferences.LocalLlmPreferences +import com.itsaky.androidide.plugins.aiagentlocal.prompt.LocalSystemPrompt +import com.itsaky.androidide.plugins.aiagentlocal.prompt.config.LocalPromptConfig +import com.itsaky.androidide.plugins.aiagentlocal.prompt.config.sharedPromptConfig import com.itsaky.androidide.plugins.extensions.DocumentationExtension import com.itsaky.androidide.plugins.extensions.PluginTooltipButton import com.itsaky.androidide.plugins.extensions.PluginTooltipEntry import com.itsaky.androidide.plugins.services.LlmInferenceService import com.itsaky.androidide.plugins.services.SharedServices +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.cancel /** * Registers the on-device llama.cpp backend with AI Core's inference router. @@ -32,6 +42,9 @@ class LocalLlmPlugin : IPlugin, DocumentationExtension { /** True once [backend] is registered with the router, so re-registration is idempotent. */ @Volatile private var registered = false + /** Runs the prompt-config load while the plugin is active; cancelled on deactivation. */ + @Volatile private var configScope: CoroutineScope? = null + companion object { const val PLUGIN_ID = "com.itsaky.androidide.plugins.aiagentlocal" @@ -110,8 +123,9 @@ class LocalLlmPlugin : IPlugin, DocumentationExtension { // A half-failed activation can leave a backend behind; keep at most one live. releaseBackend() + preloadPromptConfig() - backend = LocalLlmBackend(context) + backend = LocalLlmBackend(context, sharedPromptConfig::configIfLoaded) // Listen first, then try: a listener added after a successful attempt would still be // needed for a later AI Core restart, and one added before costs nothing. @@ -167,6 +181,45 @@ class LocalLlmPlugin : IPlugin, DocumentationExtension { null } + /** Reads and validates the prompt config now, so building a prompt does no disk I/O. */ + @OptIn(ExperimentalCoroutinesApi::class) + private fun preloadPromptConfig() { + releasePromptConfig() + val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default) + configScope = scope + val source = AssetPromptConfigSource(context.androidContext.assets) + val load = sharedPromptConfig.preload(scope, source) + load.invokeOnCompletion { error -> + when (error) { + null -> reportLoadedConfig(load.getCompleted()) + is CancellationException -> Unit + else -> context.logger.error( + "LocalLlmPlugin: prompt config failed to load; ai-core's default prompt is sent instead", + error, + ) + } + } + } + + /** + * Logs that the config loaded, and any name typo its layout would hit at render time. + * + * @param config the config just loaded. + */ + private fun reportLoadedConfig(config: LocalPromptConfig) { + context.logger.info("LocalLlmPlugin: loaded prompt config with ${config.rules.size} rule groups") + for (problem in LocalSystemPrompt.problems(config)) { + context.logger.warn("LocalLlmPlugin: $problem; ai-core's default prompt is sent instead") + } + } + + /** Drops the cached config and stops a load still in flight. Idempotent. */ + private fun releasePromptConfig() { + sharedPromptConfig.clear() + configScope?.cancel() + configScope = null + } + override fun deactivate(): Boolean { context.logger.info("LocalLlmPlugin: Deactivating plugin") @@ -182,6 +235,7 @@ class LocalLlmPlugin : IPlugin, DocumentationExtension { // A disabled plugin must not keep the loaded model resident in host RAM. releaseBackend() + releasePromptConfig() true } catch (e: Exception) { @@ -208,6 +262,7 @@ class LocalLlmPlugin : IPlugin, DocumentationExtension { runCatching { context.removePluginLifecycleListener(aiCoreLifecycle) } releaseBackend() + releasePromptConfig() pluginContext = null context.logger.info("LocalLlmPlugin: Released local LLM backend") } diff --git a/plugins/AI-Agent-Local/src/main/kotlin/com/itsaky/androidide/plugins/aiagentlocal/prompt/LocalPromptVariables.kt b/plugins/AI-Agent-Local/src/main/kotlin/com/itsaky/androidide/plugins/aiagentlocal/prompt/LocalPromptVariables.kt new file mode 100644 index 00000000..a9a061f6 --- /dev/null +++ b/plugins/AI-Agent-Local/src/main/kotlin/com/itsaky/androidide/plugins/aiagentlocal/prompt/LocalPromptVariables.kt @@ -0,0 +1,82 @@ +package com.itsaky.androidide.plugins.aiagentlocal.prompt + +import com.itsaky.androidide.plugins.aiagentlocal.prompt.config.LocalPromptConfig +import com.itsaky.androidide.plugins.services.LlmInferenceService.SystemPromptRequest + +/** + * The values the prompt config is rendered with: its texts, named by YAML path, and the request's. + * Every key is always present, empty when it does not apply, so a name missing here is a typo in + * a file and fails the render instead of silently dropping text. + */ +internal object LocalPromptVariables { + + // Config texts: `identity` is IDENTITY, `tools.heading` is TOOLS_HEADING, and so on. + const val IDENTITY = "IDENTITY" + const val RULES = "RULES" + const val HEADING = "HEADING" + const val ITEMS = "ITEMS" + const val TEXT = "TEXT" + const val TOOLS_HEADING = "TOOLS_HEADING" + const val TOOL_CALL_FORMAT_TEXT_INSTRUCTION = "TOOL_CALL_FORMAT_TEXT_INSTRUCTION" + const val TOOL_CALL_FORMAT_TEXT_EXAMPLES_HEADING = "TOOL_CALL_FORMAT_TEXT_EXAMPLES_HEADING" + const val TOOL_CALL_FORMAT_TEXT_EXAMPLES = "TOOL_CALL_FORMAT_TEXT_EXAMPLES" + const val PURPOSE = "PURPOSE" + const val CALL = "CALL" + + /** The tools the request offers, each with [NAME] and [DESCRIPTION]. */ + const val TOOLS = "TOOLS" + + /** The tool-call envelope; null when the caller parses none, which drops the call format. */ + const val TOOL_CALL_SYNTAX = "TOOL_CALL_SYNTAX" + + /** A real project path to show in examples. */ + const val EXAMPLE_FILE_PATH = "EXAMPLE_FILE_PATH" + + /** [EXAMPLE_FILE_PATH]'s bare file name, which read_file and open_file accept. */ + const val EXAMPLE_FILE_NAME = "EXAMPLE_FILE_NAME" + + /** [EXAMPLE_FILE_NAME] without its extension, for search examples. */ + const val EXAMPLE_FILE_STEM = "EXAMPLE_FILE_STEM" + + /** A tool's name, inside `{{#TOOLS}}`. */ + const val NAME = "NAME" + + /** A tool's description, inside `{{#TOOLS}}`. */ + const val DESCRIPTION = "DESCRIPTION" + + /** Path used in examples when the caller names none, so they still show a concrete shape. */ + const val FALLBACK_EXAMPLE_PATH = "app/src/main/java/com/example/MainActivity.kt" + + /** + * Collects every value `layout.system_prompt` may use. + * + * @param config the loaded prompt config. + * @param request the tool list, envelope syntax and example path to describe. + * @return the values, keyed by name. + */ + fun collect(config: LocalPromptConfig, request: SystemPromptRequest): Map { + val examplePath = request.exampleFilePath ?: FALLBACK_EXAMPLE_PATH + val exampleName = examplePath.substringAfterLast('/') + val format = config.toolCallFormat.text + return mapOf( + IDENTITY to config.identity, + RULES to config.rules.map { group -> + mapOf(HEADING to group.heading, ITEMS to group.items.map { mapOf(TEXT to it) }) + }, + TOOLS_HEADING to config.tools.heading, + TOOL_CALL_FORMAT_TEXT_INSTRUCTION to format.instruction, + TOOL_CALL_FORMAT_TEXT_EXAMPLES_HEADING to format.examplesHeading, + TOOL_CALL_FORMAT_TEXT_EXAMPLES to format.examples.map { + mapOf(PURPOSE to it.purpose, CALL to it.call) + }, + // Plain Strings, so a contributed tool's description is never rendered as a template. + TOOLS to request.tools.map { mapOf(NAME to it.name, DESCRIPTION to it.description) }, + EXAMPLE_FILE_PATH to examplePath, + EXAMPLE_FILE_NAME to exampleName, + // A dotfile's name is all extension, so its stem would be an empty search. + EXAMPLE_FILE_STEM to exampleName.substringBeforeLast('.').ifEmpty { exampleName }, + // Null syntax: the caller parses no envelope, so none is taught (ADFA-5410). + TOOL_CALL_SYNTAX to request.toolCallSyntax, + ) + } +} diff --git a/plugins/AI-Agent-Local/src/main/kotlin/com/itsaky/androidide/plugins/aiagentlocal/prompt/LocalSystemPrompt.kt b/plugins/AI-Agent-Local/src/main/kotlin/com/itsaky/androidide/plugins/aiagentlocal/prompt/LocalSystemPrompt.kt index 6aca9a15..f3fe041b 100644 --- a/plugins/AI-Agent-Local/src/main/kotlin/com/itsaky/androidide/plugins/aiagentlocal/prompt/LocalSystemPrompt.kt +++ b/plugins/AI-Agent-Local/src/main/kotlin/com/itsaky/androidide/plugins/aiagentlocal/prompt/LocalSystemPrompt.kt @@ -1,77 +1,53 @@ package com.itsaky.androidide.plugins.aiagentlocal.prompt +import com.itsaky.androidide.plugins.ai.prompt.PromptTemplateEngine +import com.itsaky.androidide.plugins.aiagentlocal.prompt.config.LocalPromptConfig import com.itsaky.androidide.plugins.services.LlmInferenceService.SystemPromptRequest +import com.itsaky.androidide.plugins.services.LlmInferenceService.ToolDefinition /** - * The system prompt this backend asks for. - * - * Written for small on-device models, which is why it reads as it does: one instruction per line, - * every rule stated as a prohibition, and more worked examples than prose. A 1–3B model handed the - * high-autonomy phrasing a cloud model thrives on tends to narrate the action instead of emitting - * the call. That is a property of the model, so the prompt lives with the backend that runs it. - * - * Pure and free of Android types, so it is unit-testable without a device. + * The system prompt this backend asks for: `layout.yml`'s `system_prompt`, rendered in one pass. + * Written for a small on-device model, so the wording lives with the backend that talks to it; + * knows no wording itself, which is the config's. Pure and thread-safe. */ internal object LocalSystemPrompt { /** - * Path used in the examples when the caller names none, so they still show a concrete shape. + * Builds the prompt; the envelope and its examples appear only when the caller parses text. + * + * @param request the tool list, envelope syntax and example path to describe. + * @param config the loaded prompt config. + * @return the system prompt, without the caller's IDE-context block. */ - private const val FALLBACK_EXAMPLE_PATH = "app/src/main/java/com/example/MainActivity.kt" + fun build(request: SystemPromptRequest, config: LocalPromptConfig): String = + PromptTemplateEngine.render(config.layout.systemPrompt, LocalPromptVariables.collect(config, request)) + .trimEnd() /** - * Builds the prompt for [request]. - * - * [SystemPromptRequest.toolCallSyntax] is reproduced verbatim — a paraphrase would produce - * replies nothing reads — and a null one means the caller parses no envelope, so the format - * section and its examples are left out rather than taught in a syntax nothing reads back. + * Renders requests that open and close every section, to catch a name typo. * - * @return the system prompt, without the caller's IDE-context block + * @param config the loaded prompt config. + * @return one message per distinct failure; empty when every request renders. */ - fun build(request: SystemPromptRequest): String { - val toolDescriptions = request.tools.joinToString("\n") { "- ${it.name}: ${it.description}" } - val examplePath = request.exampleFilePath ?: FALLBACK_EXAMPLE_PATH - val exampleName = examplePath.substringAfterLast('/') - val exampleStem = exampleName.substringBeforeLast('.') - - val rules = """ - You are a coding assistant inside CodeOnTheGo. - - 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. - - read_file and open_file accept a bare file name (the project is searched for it). Never invent deep paths. - - To change a file, use edit_file, not update_file. Call read_file FIRST, then copy the text to replace into "old_string" EXACTLY as it appears in that output (same spelling, same indentation). It must appear only once — include the line above or below if it doesn't. - - "old_string" is the text that is in the file NOW; "new_string" is what it should become. They must differ. To rename x to y: old_string has x, new_string has y. - - Never put a real line break inside an argument value: write it as \n. Keep old_string/new_string to a few lines; make several small edits rather than one big one. - - edit_file needs a real path, not a bare name, and never a path you invented. If you don't know it, call search_project with the file name FIRST and use the path it returns — don't guess the folders, and don't guess the extension (.kt vs .java). - - To rename something everywhere in a file, make ONE edit_file call with old_string set to just the old name and "replace_all":"true". - - Tools: - $toolDescriptions - """.trimIndent() - - val syntax = request.toolCallSyntax ?: return rules - - return rules + "\n\n" + """ - TOOL CALL FORMAT — emit a single line in EXACTLY this format and nothing after it: - $syntax - - Examples (pick the tool that matches; copy the FORMAT, not the values): - Greeting / question you can answer -> respond: - {"tool":"respond","args":{"message":"Hi! What would you like to build?"}} - Open a file (a bare name is fine here) -> open_file: - {"tool":"open_file","args":{"file_path":"$exampleName"}} - Change one line of a file -> edit_file: - {"tool":"edit_file","args":{"file_path":"$examplePath","old_string":"setTitle(\"Old\")","new_string":"setTitle(\"New\")"}} - Change two lines (note the \n, never a real line break) -> edit_file: - {"tool":"edit_file","args":{"file_path":"$examplePath","old_string":"a = 1\nb = 2","new_string":"a = 10\nb = 20"}} - Rename every use of one name in a file -> ONE edit_file with replace_all (NOT one call per line): - {"tool":"edit_file","args":{"file_path":"$examplePath","old_string":"oldName","new_string":"newName","replace_all":"true"}} - Find where a file actually lives before editing it -> search_project: - {"tool":"search_project","args":{"query":"$exampleStem"}} - """.trimIndent() + fun problems(config: LocalPromptConfig): List = + CHECK_REQUESTS.mapNotNull { request -> + try { + build(request, config) + null + } catch (e: IllegalArgumentException) { + e.message + } + }.distinct() + + /** The text protocol and none, two tools and none, a real path and the fallback. */ + private val CHECK_REQUESTS: List = run { + val tools = listOf( + ToolDefinition("read_file", "Read a file.", emptyMap()), + ToolDefinition("respond", "Reply.", emptyMap()), + ) + listOf( + SystemPromptRequest(tools, "…", "app/Main.kt"), + SystemPromptRequest(emptyList(), null, null), + ) } } diff --git a/plugins/AI-Agent-Local/src/main/kotlin/com/itsaky/androidide/plugins/aiagentlocal/prompt/config/LocalPromptConfig.kt b/plugins/AI-Agent-Local/src/main/kotlin/com/itsaky/androidide/plugins/aiagentlocal/prompt/config/LocalPromptConfig.kt new file mode 100644 index 00000000..69ebd9e6 --- /dev/null +++ b/plugins/AI-Agent-Local/src/main/kotlin/com/itsaky/androidide/plugins/aiagentlocal/prompt/config/LocalPromptConfig.kt @@ -0,0 +1,63 @@ +package com.itsaky.androidide.plugins.aiagentlocal.prompt.config + +import com.itsaky.androidide.plugins.ai.prompt.PromptConfigLoader +import com.itsaky.androidide.plugins.ai.prompt.PromptText + + +/** + * The local model's system prompt as `assets/prompts/` declares it: the wording, and the layout + * that arranges it. Loaded by [PromptConfigLoader]; immutable, so one instance serves every request. + * + * @property identity who the agent is. + * @property rules the rules, in the order they are sent. + * @property tools the wording around the tool list. + * @property toolCallFormat how to call a tool; a small model calls through the text protocol only. + * @property layout where each text goes. + */ +data class LocalPromptConfig( + val identity: PromptText, + val rules: List, + val tools: Tools, + val toolCallFormat: ToolCallFormat, + val layout: Layout, +) { + + /** + * One group of rules. + * + * @property heading the group's name, e.g. `Rules`. + * @property items the rules, one sentence each. + */ + data class RuleGroup(val heading: PromptText, val items: List) + + /** @property heading what introduces the tool list. */ + data class Tools(val heading: PromptText) + + /** @property text how to call when calls travel in the reply, the only way this backend calls. */ + data class ToolCallFormat(val text: TextFormat) + + /** + * @property instruction the sentence introducing the envelope. + * @property examplesHeading what introduces [examples]. + * @property examples well-formed calls, each with what it is for. + */ + data class TextFormat( + val instruction: PromptText, + val examplesHeading: PromptText, + val examples: List, + ) + + /** + * @property purpose what the call does, and which tool does it. + * @property call the call, as the model should write it. + */ + data class Example(val purpose: PromptText, val call: PromptText) + + /** @property systemPrompt the whole prompt; ai-core appends its IDE CONTEXT block after it. */ + data class Layout(val systemPrompt: PromptText) + + companion object { + /** The `schema_version` this code reads; bump it when a key is renamed or removed. */ + const val SCHEMA_VERSION = 1 + } +} diff --git a/plugins/AI-Agent-Local/src/main/kotlin/com/itsaky/androidide/plugins/aiagentlocal/prompt/config/LocalPromptConfigParser.kt b/plugins/AI-Agent-Local/src/main/kotlin/com/itsaky/androidide/plugins/aiagentlocal/prompt/config/LocalPromptConfigParser.kt new file mode 100644 index 00000000..41df0c12 --- /dev/null +++ b/plugins/AI-Agent-Local/src/main/kotlin/com/itsaky/androidide/plugins/aiagentlocal/prompt/config/LocalPromptConfigParser.kt @@ -0,0 +1,53 @@ +package com.itsaky.androidide.plugins.aiagentlocal.prompt.config + +import com.itsaky.androidide.plugins.ai.prompt.PromptConfigDocument +import com.itsaky.androidide.plugins.ai.prompt.PromptConfigException +import com.itsaky.androidide.plugins.ai.prompt.PromptConfigLoader +import com.itsaky.androidide.plugins.ai.prompt.PromptConfigParser +import com.itsaky.androidide.plugins.aiagentlocal.prompt.config.LocalPromptConfig.Example +import com.itsaky.androidide.plugins.aiagentlocal.prompt.config.LocalPromptConfig.Layout +import com.itsaky.androidide.plugins.aiagentlocal.prompt.config.LocalPromptConfig.RuleGroup +import com.itsaky.androidide.plugins.aiagentlocal.prompt.config.LocalPromptConfig.TextFormat +import com.itsaky.androidide.plugins.aiagentlocal.prompt.config.LocalPromptConfig.ToolCallFormat +import com.itsaky.androidide.plugins.aiagentlocal.prompt.config.LocalPromptConfig.Tools + +/** + * Maps the merged config onto a [LocalPromptConfig]. Strict: a missing, mistyped or unknown key + * throws [PromptConfigException] naming the file that holds it, so a typo fails on activation. + */ +object LocalPromptConfigParser : PromptConfigParser { + + /** + * Parses the config merged from `agent.yml` and its includes; see [PromptConfigLoader]. + * + * @param document the merged top-level keys and the file each came from. + * @return the config. + */ + override fun parse(document: PromptConfigDocument): LocalPromptConfig = + document.read { + val version = int("schema_version") + if (version != LocalPromptConfig.SCHEMA_VERSION) { + val supported = LocalPromptConfig.SCHEMA_VERSION + throw invalid("schema_version", "is $version, but this local-model plugin reads $supported") + } + LocalPromptConfig( + identity = text("identity"), + rules = objects("rules").map { it.read { RuleGroup(text("heading"), texts("items")) } }, + tools = obj("tools").read { Tools(text("heading")) }, + toolCallFormat = obj("tool_call_format").read { + ToolCallFormat( + text = obj("text").read { + TextFormat( + instruction = text("instruction"), + examplesHeading = text("examples_heading"), + examples = objects("examples").map { example -> + example.read { Example(text("purpose"), text("call")) } + }, + ) + }, + ) + }, + layout = obj("layout").read { Layout(text("system_prompt")) }, + ) + } +} diff --git a/plugins/AI-Agent-Local/src/main/kotlin/com/itsaky/androidide/plugins/aiagentlocal/prompt/config/SharedPromptConfig.kt b/plugins/AI-Agent-Local/src/main/kotlin/com/itsaky/androidide/plugins/aiagentlocal/prompt/config/SharedPromptConfig.kt new file mode 100644 index 00000000..685eca0c --- /dev/null +++ b/plugins/AI-Agent-Local/src/main/kotlin/com/itsaky/androidide/plugins/aiagentlocal/prompt/config/SharedPromptConfig.kt @@ -0,0 +1,8 @@ +package com.itsaky.androidide.plugins.aiagentlocal.prompt.config + +import com.itsaky.androidide.plugins.ai.prompt.PromptConfigStore + + + +/** This plugin's prompt config, filled on activation and read by every chat turn. */ +val sharedPromptConfig: PromptConfigStore = PromptConfigStore(LocalPromptConfigParser) diff --git a/plugins/AI-Agent-Local/src/test/kotlin/com/itsaky/androidide/plugins/aiagentlocal/backend/LocalLlmBackendTest.kt b/plugins/AI-Agent-Local/src/test/kotlin/com/itsaky/androidide/plugins/aiagentlocal/backend/LocalLlmBackendTest.kt index fdd2090c..a2056d5c 100644 --- a/plugins/AI-Agent-Local/src/test/kotlin/com/itsaky/androidide/plugins/aiagentlocal/backend/LocalLlmBackendTest.kt +++ b/plugins/AI-Agent-Local/src/test/kotlin/com/itsaky/androidide/plugins/aiagentlocal/backend/LocalLlmBackendTest.kt @@ -1,6 +1,7 @@ package com.itsaky.androidide.plugins.aiagentlocal.backend import android.content.Context +import android.content.SharedPreferences import com.itsaky.androidide.plugins.PluginContext import com.itsaky.androidide.plugins.aiagentlocal.feedback.IncompatibleModelException import com.itsaky.androidide.plugins.aiagentlocal.feedback.ModelLoadException @@ -116,16 +117,26 @@ class LocalLlmBackendTest { every { androidContext.filesDir } returns filesDir pluginContext = mockk(relaxed = true) every { pluginContext.androidContext } returns androidContext - backend = LocalLlmBackend(pluginContext) + backend = LocalLlmBackend(pluginContext, { null }) } - private fun backendWith(source: NativeModelSource) = LocalLlmBackend(pluginContext, source) + private fun backendWith(source: NativeModelSource) = LocalLlmBackend(pluginContext, { null }, source) + + @Test + fun givenTheShortPromptOnAndTemplatesNotYetLoaded_whenAskedForItsPrompt_thenItReturnsNull() { + // Null is the contract's "no prompt of my own": ai-core then sends its default prompt. + val prefs = mockk(relaxed = true) + every { prefs.getBoolean(any(), any()) } returns true + every { pluginContext.getPluginSharedPreferences(any()) } returns prefs + + assertNull(backend.getSystemPrompt(SystemPromptRequest(emptyList(), null, "app/Main.kt"))) + } private fun backendWith( source: NativeModelSource, engine: ModelResidencyEngine, watcher: ModelSourceWatcher = FakeWatcher(), - ) = LocalLlmBackend(pluginContext, source, engine, watcher) + ) = LocalLlmBackend(pluginContext, { null }, source, engine, watcher) @Test fun testBackendId() { diff --git a/plugins/AI-Agent-Local/src/test/kotlin/com/itsaky/androidide/plugins/aiagentlocal/prompt/LocalSystemPromptTest.kt b/plugins/AI-Agent-Local/src/test/kotlin/com/itsaky/androidide/plugins/aiagentlocal/prompt/LocalSystemPromptTest.kt new file mode 100644 index 00000000..c9eefd76 --- /dev/null +++ b/plugins/AI-Agent-Local/src/test/kotlin/com/itsaky/androidide/plugins/aiagentlocal/prompt/LocalSystemPromptTest.kt @@ -0,0 +1,176 @@ +package com.itsaky.androidide.plugins.aiagentlocal.prompt + +import com.itsaky.androidide.plugins.services.LlmInferenceService.SystemPromptRequest +import com.itsaky.androidide.plugins.services.LlmInferenceService.ToolDefinition +import com.itsaky.androidide.plugins.aiagentlocal.prompt.config.DirectoryPromptConfigSource.Companion.shippedConfig +import com.itsaky.androidide.plugins.aiagentlocal.prompt.config.DirectoryPromptConfigSource.Companion.shippedWith +import com.itsaky.androidide.plugins.aiagentlocal.prompt.config.LocalPromptConfig +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +/** + * Unit tests for [LocalSystemPrompt]. The 1–3B models this prompt is written for are the ones most + * likely to refuse an off-domain question, and the least likely to recover from a mangled envelope. + * Also: its wording changes by editing `assets/prompts/` alone, and a typo is caught, by file. + */ +class LocalSystemPromptTest { + + private companion object { + const val SYNTAX = """{"tool":"TOOL_NAME","args":{"arg":"value"}}""" + } + + private fun prompt( + toolCallSyntax: String? = SYNTAX, + examplePath: String? = "app/src/main/java/com/example/MainActivity.kt", + config: LocalPromptConfig = shippedConfig, + ) = LocalSystemPrompt.build( + SystemPromptRequest( + listOf( + ToolDefinition("read_file", "Read a file", emptyMap()), + ToolDefinition("respond", "Finish the task", emptyMap()), + ), + toolCallSyntax, + examplePath, + ), + config, + ) + + @Test + fun givenAnEnvelopeSyntax_whenBuilding_thenItIsReproducedVerbatim() { + assertTrue(prompt().contains(SYNTAX)) + } + + @Test + fun givenNoEnvelopeSyntax_whenBuilding_thenTheEnvelopeIsNeverTaught() { + // The caller parses no envelope here, so an example of one is a call that would not run. + assertFalse(prompt(toolCallSyntax = null).contains("")) + } + + @Test + fun givenEitherMode_whenBuilding_thenEachToolIsListed() { + listOf(SYNTAX, null).forEach { syntax -> + val prompt = prompt(toolCallSyntax = syntax) + + assertTrue(prompt.contains("- read_file: Read a file")) + assertTrue(prompt.contains("- respond: Finish the task")) + } + } + + @Test + fun givenEitherMode_whenBuilding_thenAnOffDomainRequestIsNeverDeclined() { + // A prompt that named only Android left a general question no legal path through it, and + // the model declined rather than answer (ADFA-6223). + listOf(SYNTAX, null).forEach { syntax -> + val prompt = prompt(toolCallSyntax = syntax) + + assertTrue(prompt.contains("You answer anything the user asks, not only Android questions")) + assertTrue( + prompt.contains("Never refuse a question because it is not about Android") + ) + } + } + + @Test + fun givenEitherMode_whenBuilding_thenTheNonRefusalRuleSaysHowToAnswer() { + // "Reply with exactly ONE tool call" leaves no way to answer in prose, so a rule that only + // forbids refusing without naming "respond" asks for a reply the loop cannot deliver. + listOf(SYNTAX, null).forEach { syntax -> + val prompt = prompt(toolCallSyntax = syntax) + val rule = prompt.lineSequence().first { it.contains("Never refuse a question") } + + assertTrue(rule, rule.contains(""""respond"""")) + } + } + + @Test + fun givenNoExamplePath_whenBuilding_thenTheExamplesStillCarryAConcretePath() { + assertTrue( + prompt(examplePath = null) + .contains(""""file_path":"app/src/main/java/com/example/MainActivity.kt"""") + ) + } + + @Test + fun givenAnExamplePath_whenBuilding_thenTheBareNameAndStemAreDerivedFromIt() { + val prompt = prompt(examplePath = "src/Foo.kt") + + assertTrue(prompt.contains(""""file_path":"Foo.kt"""")) + assertTrue(prompt.contains(""""query":"Foo"""")) + } + + @Test + fun givenADotfileExamplePath_whenBuilding_thenTheStemIsItsWholeName() { + assertTrue(prompt(examplePath = "app/.gitignore").contains(""""query":".gitignore"""")) + } + + @Test + fun givenEitherMode_whenBuilding_thenExactlyOneWayToCallAToolIsTaught() { + // Teaching both (ADFA-5410) is how one call runs twice: the provider carries it and the + // text copy is extracted as a second call. + listOf(SYNTAX, null).forEach { syntax -> + val prompt = prompt(toolCallSyntax = syntax) + + assertEquals( + if (syntax == null) 0 else 1, + prompt.split("TOOL CALL FORMAT").size - 1, + ) + } + } + + @Test + fun givenSeveralTools_whenBuilding_thenNoLineIsIndented() { + // The Kotlin version interpolated the tool list into a raw string, which defeated + // trimIndent and sent the rules indented by eight spaces. + listOf(SYNTAX, null).forEach { syntax -> + assertFalse(prompt(toolCallSyntax = syntax).lines().any { it.startsWith(" ") }) + } + } + + @Test + fun givenTheShippedFiles_whenChecked_thenThePromptRendersForEveryRequest() { + // A typo fails the render, so the shipped set must have none. + assertEquals(emptyList(), LocalSystemPrompt.problems(shippedConfig)) + } + + @Test + fun givenATypo_whenChecked_thenItIsReportedByItsFileAndPathRatherThanDroppingText() { + // The file-per-section design this replaced dropped a file with a typo silently. + val config = shippedWith("tools.yml") { it.replace("{{EXAMPLE_FILE_NAME}}", "{{EXAMPLE_NAME}}") } + + assertEquals( + listOf("tools.yml: tool_call_format.text.examples[1].call: unknown name {{EXAMPLE_NAME}}"), + LocalSystemPrompt.problems(config), + ) + } + + @Test + fun givenANewRule_whenBuilding_thenItIsSentAmongTheRulesWithNoCodeChange() { + val config = shippedWith("rules.yml") { it + " - NEW RULE.\n" } + + val prompt = prompt(config = config) + + assertTrue(prompt.indexOf("Rules:") < prompt.indexOf("- NEW RULE.")) + assertTrue(prompt.indexOf("- NEW RULE.") < prompt.indexOf("Tools:")) + } + + @Test + fun givenANewIdentity_whenBuilding_thenTheToneChangesWithNoCodeChange() { + val config = shippedWith("agent.yml") { + it.replace(Regex("(?s)identity: >-\n.*?\n\n"), "identity: Eres un asistente de programación.\n\n") + } + + assertTrue(prompt(config = config).startsWith("Eres un asistente de programación.\n\nRules:")) + } + + @Test + fun givenAReorderedLayout_whenBuilding_thenTheSectionsFollowIt() { + // The order the model reads things in is config too, not code. + val config = shippedWith("layout.yml") { + it.replace(" {{IDENTITY}}\n\n", " {{TOOLS_HEADING}}!\n {{IDENTITY}}\n\n") + } + + assertTrue(prompt(config = config).startsWith("Tools!\nYou are a coding assistant")) + } +} diff --git a/plugins/AI-Agent-Local/src/test/kotlin/com/itsaky/androidide/plugins/aiagentlocal/prompt/config/DirectoryPromptConfigSource.kt b/plugins/AI-Agent-Local/src/test/kotlin/com/itsaky/androidide/plugins/aiagentlocal/prompt/config/DirectoryPromptConfigSource.kt new file mode 100644 index 00000000..2338a4ab --- /dev/null +++ b/plugins/AI-Agent-Local/src/test/kotlin/com/itsaky/androidide/plugins/aiagentlocal/prompt/config/DirectoryPromptConfigSource.kt @@ -0,0 +1,46 @@ +package com.itsaky.androidide.plugins.aiagentlocal.prompt.config + +import com.itsaky.androidide.plugins.ai.prompt.PromptConfigLoader +import com.itsaky.androidide.plugins.ai.prompt.PromptConfigSource +import java.io.File +import java.io.FileNotFoundException +import kotlinx.coroutines.runBlocking + +/** + * Reads config from a directory, so JVM tests render the exact files the `.cgp` ships. + * + * @param root the directory holding the config files. + * @param edits replaces one file's text before it is returned, as a device would see an edited file. + */ +class DirectoryPromptConfigSource( + private val root: File, + private val edits: Map String> = emptyMap(), +) : PromptConfigSource { + + override fun read(path: String): String { + val file = File(root, path) + if (!file.isFile) throw FileNotFoundException(path) + return edits[path]?.invoke(file.readText()) ?: file.readText() + } + + companion object { + /** The shipped config files; unit tests run with the module directory as working dir. */ + val SHIPPED_ROOT = File("src/main/assets/prompts") + + /** The shipped config, loaded once for every test that renders a prompt. */ + val shippedConfig: LocalPromptConfig by lazy { load(DirectoryPromptConfigSource(SHIPPED_ROOT)) } + + /** + * Loads the shipped config with one file rewritten by [edit]. + * + * @param file the file to edit, e.g. `rules.yml`. + * @param edit rewrites that file's text. + * @return the config loaded from the edited files. + */ + fun shippedWith(file: String, edit: (String) -> String): LocalPromptConfig = + load(DirectoryPromptConfigSource(SHIPPED_ROOT, mapOf(file to edit))) + + private fun load(source: PromptConfigSource): LocalPromptConfig = + runBlocking { PromptConfigLoader.load(source, LocalPromptConfigParser) } + } +} diff --git a/plugins/AI-Agent-Local/src/test/kotlin/com/itsaky/androidide/plugins/aiagentlocal/prompt/config/LocalPromptConfigParserTest.kt b/plugins/AI-Agent-Local/src/test/kotlin/com/itsaky/androidide/plugins/aiagentlocal/prompt/config/LocalPromptConfigParserTest.kt new file mode 100644 index 00000000..860336d0 --- /dev/null +++ b/plugins/AI-Agent-Local/src/test/kotlin/com/itsaky/androidide/plugins/aiagentlocal/prompt/config/LocalPromptConfigParserTest.kt @@ -0,0 +1,98 @@ +package com.itsaky.androidide.plugins.aiagentlocal.prompt.config + +import com.itsaky.androidide.plugins.ai.prompt.PromptConfigException +import com.itsaky.androidide.plugins.aiagentlocal.prompt.config.DirectoryPromptConfigSource.Companion.shippedConfig +import com.itsaky.androidide.plugins.aiagentlocal.prompt.config.DirectoryPromptConfigSource.Companion.shippedWith +import org.junit.Assert.assertEquals +import org.junit.Assert.assertThrows +import org.junit.Assert.assertTrue +import org.junit.Test + +/** + * Unit tests for [LocalPromptConfigParser]: a mistake in a prompt file is refused naming that file + * and the key, rather than reaching the model as a prompt with a hole in it. + */ +class LocalPromptConfigParserTest { + + @Test + fun givenTheShippedFiles_whenParsing_thenEveryTextIsLabelledWithItsOwnFileAndPath() { + assertEquals("agent.yml: identity", shippedConfig.identity.label) + assertEquals("rules.yml: rules[0].items[2]", shippedConfig.rules[0].items[2].label) + assertEquals( + "tools.yml: tool_call_format.text.examples[1].call", + shippedConfig.toolCallFormat.text.examples[1].call.label, + ) + assertEquals("layout.yml: layout.system_prompt", shippedConfig.layout.systemPrompt.label) + } + + @Test + fun givenAFoldedScalar_whenParsing_thenItsLinesAreJoinedIntoOneSentence() { + // Source line wraps must not reach the model as newlines mid-sentence. + val purpose = shippedConfig.toolCallFormat.text.examples[4].purpose.template + + assertTrue(purpose.endsWith("(NOT one call per line)")) + assertTrue('\n' !in purpose) + } + + @Test + fun givenAMissingNestedKey_whenParsing_thenItIsNamedWithItsFileAndPath() { + assertRefused("tools.yml: tool_call_format.text.examples_heading is missing", "tools.yml") { + it.replace(Regex("(?m)^ examples_heading: .*\n"), "") + } + } + + @Test + fun givenAMissingTopLevelKey_whenParsing_thenItIsReportedAgainstTheEntryFile() { + // No file holds it, so the entry file, which decides what is read, is the one to fix. + assertRefused("agent.yml: rules is missing", "rules.yml") { "other: x\n" } + } + + @Test + fun givenANativeFormat_whenParsing_thenItIsRefusedAsUnknown() { + // This backend only calls through the text protocol; a native format would never be sent. + assertRefused("tools.yml: tool_call_format: unknown key native; expected text", "tools.yml") { + it.replace("tool_call_format:\n", "tool_call_format:\n native: Call natively.\n") + } + } + + @Test + fun givenAnUnquotedNumber_whenParsing_thenItIsRefusedAsNotText() { + assertRefused("tools.yml: tools.heading expected text; quote it", "tools.yml") { + it.replace(" heading: Tools", " heading: 42") + } + } + + @Test + fun givenAGroupWithNoRules_whenParsing_thenItIsRefused() { + assertRefused("rules.yml: rules[0].items is empty", "rules.yml") { + it.replace(Regex("(?s) items:\n.*"), " items: []\n") + } + } + + @Test + fun givenANewerSchemaVersion_whenParsing_thenItIsRefusedNamingBoth() { + assertRefused("agent.yml: schema_version is 2, but this local-model plugin reads 1", "agent.yml") { + it.replace("schema_version: 1", "schema_version: 2") + } + } + + @Test + fun givenBrokenYaml_whenParsing_thenTheFileNameAndPositionAreReported() { + val error = refused("rules.yml") { "rules: [unclosed" } + + assertTrue(error.message!!.startsWith("rules.yml: ")) + assertTrue(error.message!!.contains("line")) + } + + @Test + fun givenADuplicateKeyInOneFile_whenParsing_thenItIsRefused() { + // YAML would otherwise keep the second silently, and an edit to the first would do nothing. + refused("agent.yml") { "$it\nidentity: again\n" } + } + + private fun refused(file: String, edit: (String) -> String): PromptConfigException = + assertThrows(PromptConfigException::class.java) { shippedWith(file, edit) } + + private fun assertRefused(message: String, file: String, edit: (String) -> String) = + assertEquals(message, refused(file, edit).message) +} diff --git a/plugins/AI-Agent-Local/src/test/kotlin/com/itsaky/androidide/plugins/aiagentlocal/prompt/config/ShippedPromptFilesTest.kt b/plugins/AI-Agent-Local/src/test/kotlin/com/itsaky/androidide/plugins/aiagentlocal/prompt/config/ShippedPromptFilesTest.kt new file mode 100644 index 00000000..d2a695f6 --- /dev/null +++ b/plugins/AI-Agent-Local/src/test/kotlin/com/itsaky/androidide/plugins/aiagentlocal/prompt/config/ShippedPromptFilesTest.kt @@ -0,0 +1,55 @@ +package com.itsaky.androidide.plugins.aiagentlocal.prompt.config + +import com.itsaky.androidide.plugins.ai.prompt.PromptConfigLoader +import com.itsaky.androidide.plugins.ai.prompt.PromptConfigSource +import com.itsaky.androidide.plugins.aiagentlocal.prompt.config.DirectoryPromptConfigSource.Companion.SHIPPED_ROOT +import java.io.File +import kotlinx.coroutines.runBlocking +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +/** The shipped `assets/prompts/` files: every one included once, in order, with no malformed tag. */ +class ShippedPromptFilesTest { + + /** The shipped files, by name. */ + private val shipped: Map = + SHIPPED_ROOT.listFiles { f -> f.extension == "yml" }!!.associate { it.name to it.readText() } + + @Test + fun givenTheShippedEntryFile_whenLoading_thenItAndEveryIncludeAreReadInOrder() { + val paths = mutableListOf() + val source = PromptConfigSource { path -> paths += path; File(SHIPPED_ROOT, path).readText() } + + runBlocking { PromptConfigLoader.load(source, LocalPromptConfigParser) } + + assertEquals( + listOf("agent.yml", "rules.yml", "tools.yml", "layout.yml"), + paths, + ) + } + + @Test + fun givenEveryShippedFile_whenListed_thenEachIsIncludedExactlyOnce() { + // A .yml nobody includes is dead wording that looks live to whoever edits it. + val entry = shipped.getValue("agent.yml") + val included = Regex("(?m)^ - (\\S+\\.yml)$").findAll(entry).map { it.groupValues[1] } + + assertEquals(shipped.keys - "agent.yml", included.toSet()) + } + + @Test + fun givenTheShippedFiles_whenScanned_thenNoTagIsMalformed() { + // A `{{name}}` or `{{ #X}}` typo would reach the model verbatim, since it is no tag. + assertTrue(shipped.isNotEmpty()) + for ((name, text) in shipped) { + assertFalse("$name has a malformed tag", MALFORMED_TAG.containsMatchIn(text)) + } + } + + private companion object { + /** A `{{` that opens none of `{{NAME}}`, `{{#NAME}}`, `{{^NAME}}` or `{{/NAME}}`. */ + val MALFORMED_TAG = Regex("""\{\{(?![#^/]?[A-Z])""") + } +} diff --git a/plugins/AI-Agent-MCP/src/main/kotlin/com/itsaky/androidide/plugins/aiagentmcp/settings/McpSettingsFragment.kt b/plugins/AI-Agent-MCP/src/main/kotlin/com/itsaky/androidide/plugins/aiagentmcp/settings/McpSettingsFragment.kt index 254b1dc3..680b21d3 100644 --- a/plugins/AI-Agent-MCP/src/main/kotlin/com/itsaky/androidide/plugins/aiagentmcp/settings/McpSettingsFragment.kt +++ b/plugins/AI-Agent-MCP/src/main/kotlin/com/itsaky/androidide/plugins/aiagentmcp/settings/McpSettingsFragment.kt @@ -18,12 +18,13 @@ import androidx.lifecycle.lifecycleScope import com.google.android.material.dialog.MaterialAlertDialogBuilder import com.google.android.material.switchmaterial.SwitchMaterial import com.google.android.material.textfield.TextInputLayout +import com.itsaky.androidide.plugins.ai.ui.RevealToggle +import com.itsaky.androidide.plugins.ai.ui.SecretRevealController import com.itsaky.androidide.plugins.aiagentmcp.R import com.itsaky.androidide.plugins.aiagentmcp.client.McpTool import com.itsaky.androidide.plugins.aiagentmcp.plugin.McpPlugin import com.itsaky.androidide.plugins.aiagentmcp.tools.McpToolCatalog import com.itsaky.androidide.plugins.aiagentmcp.transport.McpHeaders -import com.itsaky.androidide.plugins.aiagentmcp.ui.SecretRevealController import com.itsaky.androidide.plugins.base.PluginFragmentHelper import com.itsaky.androidide.plugins.services.IdeTooltipService import kotlinx.coroutines.launch @@ -191,7 +192,12 @@ class McpSettingsFragment : Fragment() { tokenField.isSaveEnabled = false // The token was maskable and nothing more before this: it could only be typed blind. - tokenReveal = SecretRevealController(tokenBox, tokenField) { legible -> + tokenReveal = SecretRevealController( + tokenBox, + tokenField, + reveal = RevealToggle(R.drawable.ic_visibility, R.string.cd_show_credential), + hide = RevealToggle(R.drawable.ic_visibility_off, R.string.cd_hide_credential), + ) { legible -> setSecureWindows(legible) }.also { it.attach() } diff --git a/plugins/AI-Agent-MCP/src/main/kotlin/com/itsaky/androidide/plugins/aiagentmcp/ui/SecretRevealController.kt b/plugins/AI-Agent-MCP/src/main/kotlin/com/itsaky/androidide/plugins/aiagentmcp/ui/SecretRevealController.kt deleted file mode 100644 index 098f08a9..00000000 --- a/plugins/AI-Agent-MCP/src/main/kotlin/com/itsaky/androidide/plugins/aiagentmcp/ui/SecretRevealController.kt +++ /dev/null @@ -1,111 +0,0 @@ -package com.itsaky.androidide.plugins.aiagentmcp.ui - -import android.text.method.HideReturnsTransformationMethod -import android.text.method.PasswordTransformationMethod -import android.view.Choreographer -import android.widget.EditText -import com.google.android.material.textfield.TextInputLayout -import com.itsaky.androidide.plugins.aiagentmcp.R - -/** - * The reveal control for this plugin's masked credential field. - * - * The control is the field's own [TextInputLayout] end icon rather than a loose `ImageButton`, - * which is what gives it a real touch target wherever the field is shown. Icon, content - * description and toggle behaviour are decided here and nowhere else, so this dialog cannot drift - * from the other AI plugins' credential fields (ADFA-5491). - * - * Deliberately one copy per AI plugin: each addon is an independent Gradle build sharing only the - * repo's `libs/` jars, so there is nowhere cheaper to put this until the host's plugin-api carries - * it — a change to the masking logic is three edits, on purpose. - * - * @param box the field's own layout, whose end icon becomes the control - * @param field the masked field - * @param onLegibleChanged called with true while the secret stands in clear text, so the caller can - * flag its window secure — which window that is depends on the screen, not on this control - */ -internal class SecretRevealController( - private val box: TextInputLayout, - private val field: EditText, - private val onLegibleChanged: (legible: Boolean) -> Unit, -) { - - /** Whether the secret currently stands in clear text. */ - var isRevealed: Boolean = false - private set - - /** - * Take over [box]'s end icon and mask the field. - * - * The drawable is set here rather than in the layout because an end icon declared as - * `app:endIconDrawable` draws blank inside the host. - */ - fun attach() { - // The box exists only to carry the end icon: the dialog's other fields are plain EditTexts, - // so a floating label and a filled box would make this one field look like another control. - // The field keeps its own hint, which is what says whether a token is already stored. - box.isHintEnabled = false - box.boxBackgroundMode = TextInputLayout.BOX_BACKGROUND_NONE - box.endIconMode = TextInputLayout.END_ICON_CUSTOM - // Not announced as a toggle: with END_ICON_CUSTOM nothing ever moves the icon's checked - // state, so TalkBack would read "not checked" over a legible secret. The content - // description below carries the state instead. - box.isEndIconCheckable = false - box.setEndIconOnClickListener { toggle() } - apply() - } - - /** - * Re-mask the secret and report it illegible. - * - * Called when the dialog goes away or the screen leaves the foreground, so neither a screenshot - * nor the recents thumbnail can catch a revealed credential. - */ - fun mask() { - if (!isRevealed) return - isRevealed = false - apply() - } - - private fun toggle() { - isRevealed = !isRevealed - apply() - } - - /** Dress the field and its icon for [isRevealed], then report what is now legible. */ - private fun apply() { - field.transformationMethod = if (isRevealed) { - HideReturnsTransformationMethod.getInstance() - } else { - PasswordTransformationMethod.getInstance() - } - box.setEndIconDrawable( - if (isRevealed) R.drawable.ic_visibility_off else R.drawable.ic_visibility - ) - box.setEndIconContentDescription( - if (isRevealed) R.string.cd_hide_credential else R.string.cd_show_credential - ) - // Swapping the transformation drops the cursor to the start, so typing would continue in - // front of the token rather than after it. - field.setSelection(field.text?.length ?: 0) - // Masking only invalidates: the secret stays on screen until the next frame is drawn. - if (isRevealed) { - onLegibleChanged(true) - } else { - afterNextDraw { if (!isRevealed) onLegibleChanged(false) } - } - } - - /** - * Run [action] once the next frame has been drawn, or right away if the field is already gone: - * a frame callback runs before that frame's traversal, so a message posted from it lands after - * the field has been redrawn. - */ - private fun afterNextDraw(action: () -> Unit) { - if (!field.isAttachedToWindow) { - action() - return - } - Choreographer.getInstance().postFrameCallback { field.post(action) } - } -} diff --git a/plugins/AI-Agent-OpenAI/README.md b/plugins/AI-Agent-OpenAI/README.md index cb5a096e..84b64309 100644 --- a/plugins/AI-Agent-OpenAI/README.md +++ b/plugins/AI-Agent-OpenAI/README.md @@ -124,6 +124,45 @@ would leave the caller waiting on a call this backend never makes. The system prompt also tells the model not to use its own function-calling channel, since nothing reads it. +## System prompt config + +The prompt this backend asks ai-core to send lives in `src/main/assets/prompts/`, +one YAML file per concern, apart from the code that sends it. Changing the tone, +adding a rule or translating the prompt is an edit to those files alone. ai-core +appends its own IDE CONTEXT block after the rendered prompt. + +The files are loaded, validated and cached once, when the plugin is activated. +`getSystemPrompt` renders `layout.yml` from that cache for each request, since the +tool list, the protocol and the example path vary per run; it never waits. Until the +config has loaded, or if it cannot render, it returns null and ai-core sends its own +default prompt. + +| File | Keys | What it is | +|---|---|---| +| `agent.yml` | `schema_version`, `identity`, `include` | The entry point: the version (`1`; another is refused rather than misread), who the agent is, and the files below. | +| `scope.yml` | `scope` | What the agent will answer: anything, with the project's tools only when the request is about the open project. | +| `rules.yml` | `rules` | Rule groups, each a `heading` and its `items`; today one `RULES` group. **Adding a rule is adding an item**; a further group, e.g. by priority, renders as its own block. | +| `workflow.yml` | `behavior`, `workflow` | How to go about building or changing something; the workflow's `steps` are numbered when rendered. | +| `tools.yml` | `tools`, `tool_call_format` | What introduces the tool list, and how to call a tool: `native` under the function-calling API, `text` (with its examples) when calls travel in the reply. Exactly one is sent. | +| `layout.yml` | `layout.system_prompt` | Where each text goes. | + +The files, the names they are rendered under and the checks are AI-Agent-Gemini's +(see its README), and `scope.yml` and `workflow.yml` are identical to its copies; +edit the two plugins together. The one intended difference is +`tool_call_format.text.no_native_channel`, the line forbidding the provider's native +function-calling channel under the text protocol (`TOOL_CALL_FORMAT_TEXT_NO_NATIVE_CHANNEL`). + +Rendering is strict: an unknown name throws, naming the text it was in, where the +file-per-section design this replaced dropped the file silently. Activation renders +the prompt for requests that open and close every section and logs any failure, and +`OpenAiSystemPromptTest` fails on one in the shipped files. A new key needs +`OpenAiPromptConfig` and its parser; a new name needs `OpenAiPromptVariables`. + +The engine and the YAML plumbing (`PromptTemplateEngine`, `PromptConfigLoader`, +`PromptConfigStore`, `PromptConfigObject`, ...) are the IDE's, in `plugin-api.jar`'s +`com.itsaky.androidide.plugins.ai.prompt`, shared with ai-core and the other backends. +Only `OpenAiPromptConfig`, its mapping in `OpenAiPromptConfigParser`, and `sharedPromptConfig` are this plugin's own. + ## Key classes Every source file sits in a package named for its layer; nothing is loose at the @@ -139,7 +178,9 @@ root of `com/itsaky/androidide/plugins/aiagentopenai/`. - `errors/OpenAiErrorFormatter.kt` — turns a failure into one translated sentence - `security/SecureApiKeyStore.kt` — this plugin's Keystore alias, over the IDE's `KeystoreSecretStore` - `preferences/OpenAiPreferences.kt` — this plugin's settings store -- `prompt/OpenAiSystemPrompt.kt` — the system prompt this cloud model is given +- `prompt/OpenAiSystemPrompt.kt` — renders `layout.yml` from `OpenAiPromptVariables`; + `prompt/config/` maps `assets/prompts/` onto this plugin's config type, which the + IDE's `ai.prompt` package loads, validates, caches and renders - `settings/BaseUrlPolicy.kt` — URL normalization and the cleartext rule (pure) - `settings/ServerPreset.kt` — the one-tap server list - `settings/ConnectionVerification.kt` — what a live check established (pure) diff --git a/plugins/AI-Agent-OpenAI/build.gradle.kts b/plugins/AI-Agent-OpenAI/build.gradle.kts index 001d73ac..3fef97c9 100644 --- a/plugins/AI-Agent-OpenAI/build.gradle.kts +++ b/plugins/AI-Agent-OpenAI/build.gradle.kts @@ -64,7 +64,10 @@ dependencies { implementation("org.jetbrains.kotlin:kotlin-stdlib:2.3.21") implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.7.3") + testImplementation(files("../../libs/plugin-api.jar")) + // plugin-api's prompt loader parses YAML with the host's copy; JVM tests need their own, same version + testImplementation("org.snakeyaml:snakeyaml-engine:2.10") testImplementation("junit:junit:4.13.2") testImplementation("io.mockk:mockk:1.13.8") testImplementation("org.json:json:20231013") @@ -78,3 +81,8 @@ tasks.matching { it.name.contains("checkDebugAarMetadata") || it.name.contains("checkReleaseAarMetadata") }.configureEach { enabled = false } + +// The prompt tests read src/main/assets/prompts from disk; declared, so a YAML-only edit reruns them. +tasks.withType().configureEach { + inputs.dir("src/main/assets/prompts").withPropertyName("shippedPrompts") +} diff --git a/plugins/AI-Agent-OpenAI/src/main/assets/prompts/agent.yml b/plugins/AI-Agent-OpenAI/src/main/assets/prompts/agent.yml new file mode 100644 index 00000000..81e19f26 --- /dev/null +++ b/plugins/AI-Agent-OpenAI/src/main/assets/prompts/agent.yml @@ -0,0 +1,23 @@ +# OpenAI's system prompt: the wording this backend asks ai-core to send, apart from the code that +# sends it. Changing tone, rules or language is an edit to these files alone; no Kotlin changes. +# +# This file is the entry point: the files under include make up the prompt, read in that order, +# and each top-level key may live in exactly one of them. Every text is a template over the +# request's values, e.g. {{EXAMPLE_FILE_PATH}}; see README.md. OpenAI validates them on +# activation, and OpenAiSystemPromptTest fails on a mistake in the shipped files. ai-core appends +# its own IDE CONTEXT block after the rendered prompt. + +schema_version: 1 + +# Who the agent is; the first thing the model reads. +identity: >- + You are the coding assistant built into CodeOnTheGo, an Android IDE that runs on the user's + phone or tablet. Most requests you get are about the Android project that is open, and you have + tools for it — but you are a general assistant first. + +include: + - scope.yml + - rules.yml + - workflow.yml + - tools.yml + - layout.yml diff --git a/plugins/AI-Agent-OpenAI/src/main/assets/prompts/layout.yml b/plugins/AI-Agent-OpenAI/src/main/assets/prompts/layout.yml new file mode 100644 index 00000000..d0c3c77b --- /dev/null +++ b/plugins/AI-Agent-OpenAI/src/main/assets/prompts/layout.yml @@ -0,0 +1,56 @@ +# Where each text from the other files goes, by the name it is rendered under (see README.md). +# A line holding only a section tag (#, ^ or /) vanishes, so tags can sit on their own lines. + +layout: + system_prompt: |- + {{IDENTITY}} + + {{SCOPE_HEADING}}: + {{#SCOPE_ITEMS}} + - {{TEXT}} + {{/SCOPE_ITEMS}} + + {{TOOLS_HEADING}}: + {{#TOOLS}} + - {{NAME}}: {{DESCRIPTION}} + {{/TOOLS}} + + {{BEHAVIOR_HEADING}}: + {{#BEHAVIOR_ITEMS}} + - {{TEXT}} + {{/BEHAVIOR_ITEMS}} + + {{#RULES}} + {{^FIRST}} + + {{/FIRST}} + {{HEADING}}: + {{#ITEMS}} + - {{TEXT}} + {{/ITEMS}} + {{/RULES}} + {{#NATIVE_TOOL_CALLS}} + + {{TOOL_CALL_FORMAT_NATIVE}} + {{TOOL_CALL_FORMAT_NO_NARRATION}} + {{/NATIVE_TOOL_CALLS}} + {{#TOOL_CALL_SYNTAX}} + + {{TOOL_CALL_FORMAT_TEXT_INSTRUCTION}} + {{TOOL_CALL_SYNTAX}} + {{TOOL_CALL_FORMAT_NO_NARRATION}} + {{TOOL_CALL_FORMAT_TEXT_NO_NATIVE_CHANNEL}} + {{TOOL_CALL_FORMAT_TEXT_ONLY_THE_LINE_RUNS}} + + {{TOOL_CALL_FORMAT_TEXT_EXAMPLES_HEADING}}: + {{#TOOL_CALL_FORMAT_TEXT_EXAMPLES}} + {{PURPOSE}}: + {{CALL}} + {{/TOOL_CALL_FORMAT_TEXT_EXAMPLES}} + {{/TOOL_CALL_SYNTAX}} + + {{WORKFLOW_HEADING}}: + {{#WORKFLOW_STEPS}} + {{NUMBER}}. {{TEXT}} + {{/WORKFLOW_STEPS}} + {{WORKFLOW_CLOSING}} diff --git a/plugins/AI-Agent-OpenAI/src/main/assets/prompts/rules.yml b/plugins/AI-Agent-OpenAI/src/main/assets/prompts/rules.yml new file mode 100644 index 00000000..d4bfa8dd --- /dev/null +++ b/plugins/AI-Agent-OpenAI/src/main/assets/prompts/rules.yml @@ -0,0 +1,44 @@ +# What the agent must and must not do. Adding a rule is adding an item. Each group renders as +# "HEADING:" with its items as "- " lines; more groups, e.g. by priority, are separated by a blank line. + +rules: + - heading: RULES + items: + - >- + When you call a tool, emit ONE per reply, then stop and wait. Do NOT plan a batch: a tool + whose arguments depend on another tool's result (editing a file you just searched for) + cannot use a result you have not received yet. + - >- + To locate a file, call search_project ONCE with its name — it searches the whole project. + Never walk the tree with repeated list_files calls; you have a limited number of turns and + each level wastes one. + - >- + Renaming a symbol everywhere in a file is ONE edit_file with replace_all set to true and + old_string set to just the symbol — not one edit per line. + - >- + To change an existing file, use edit_file (find/replace an exact snippet), not update_file + — a whole-file rewrite gets truncated before it reaches disk. + - >- + Before edit_file, read the exact file you are about to edit with read_file, and copy + old_string byte-for-byte from that output, including indentation. Never edit a path you + have not confirmed exists. + - >- + old_string must be the text currently in the file and new_string what it should become. If + they are identical the edit is rejected. + - >- + Never fabricate tool output. Emit a tool call, then wait for the real result before + continuing. + - >- + Never write "User:", "Assistant:", a block, or a ```tool_response fence — + the system supplies real results. Any tool output you write yourself is a hallucination + and will be ignored. + - >- + 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. + - >- + A greeting, or a question you can answer without reading the project or checking a claim + on the web, is answered in the reply itself, with no tool call — briefly for small talk, in + full for a real question. Once you have called any tool, the task ends only with a single respond call + carrying your summary in its "message" — never an empty respond. A reply without a tool + call does not finish it. diff --git a/plugins/AI-Agent-OpenAI/src/main/assets/prompts/scope.yml b/plugins/AI-Agent-OpenAI/src/main/assets/prompts/scope.yml new file mode 100644 index 00000000..838da7f0 --- /dev/null +++ b/plugins/AI-Agent-OpenAI/src/main/assets/prompts/scope.yml @@ -0,0 +1,63 @@ +# What the agent will answer, and how it makes sure the answer is right. Each item renders as a +# "- " line under "HEADING:". Principles only: an example named here gets pattern-matched rather +# than understood, and the next defect is always a different one (ADFA-6223). + +scope: + heading: SCOPE + items: + - >- + Answer whatever the user asks. A question about another language, another platform, a + general programming concept, or something that is not about code at all is an ordinary + request: answer it directly and well. + - >- + Never decline a request on the grounds that it is not about Android, not about this + project, or not about code. You have no such restriction. + - >- + Reach for a project tool only when the request is about the open project's files. + # Confidence is the model's signal for searching, and it is highest exactly where the world + # has moved on since training; so the trigger is the kind of claim, not how sure it feels. + - >- + Your knowledge stops at a cutoff, and today's date is stated below. A claim that can stop + being true over time — whether a library, API or tool is current, deprecated or removed, + what replaced it, its latest version, the recommended way to use it — must be checked + before you make it, whenever web_search is among your tools. Judging code is such a claim: + calling code correct, current or good practice asserts that everything it uses still is. + Feeling sure is not checking. Search each claim on its own, naming exactly what you are + checking. If the results leave it open, search more precisely or read the primary source + with fetch_url; if it is still open, say what you could not verify. + - >- + The user never sees tool results, only your replies. State every fact you took from a + search or a page in the reply itself, with the link it came from next to it. + - >- + A request to review, analyze or examine code asks what is wrong with it. Check the code as + given before anything else: whether it compiles as written, whether what it uses is current, + and whether every path through it does what its author meant. Lead with the findings, each + with its evidence, before anything the code does well. + - >- + When you propose changed code, every difference from the original is a finding: state what + you changed and why, including an added import, annotation, opt-in or dependency. Your + version fixes every finding and never carries forward anything you found to be wrong. + # The self-check. Each item is a way of reasoning about code, not a list of known bugs. + - >- + Before you send code, check it as hard as you checked the user's. Trace every branch and + state to the concrete situations that reach it; if situations that need different behavior + reach the same branch, the code is wrong until you add what tells them apart. + - >- + Every operation in your code must be valid for every value its inputs can hold. Where it is + valid for only some, narrow what the code accepts or handle the rest — never assume. + - >- + Use each API the way its own documentation intends, and prefer what a library or platform + already provides over reimplementing it by hand. + - >- + Never hedge inside code — a fallback control, a comment or label saying "if this applies". + Hedging means a question is still open: resolve it, and if you cannot, say so in prose. + - >- + Code you send is complete: every import, annotation and opt-in it needs is present, and + every dependency version comes from a search result or is marked as unverified. + - >- + When a request has several parts (research, design, code), deliver every part. Do not stop + after one part to announce the next or to ask whether to proceed. + - >- + Say you cannot do something only when you genuinely cannot — you have no tool for it, it + needs information you do not have, or it is something you should not do. Say which, and say + what you can do instead. Never ask the user to do what one of your tools can do. diff --git a/plugins/AI-Agent-OpenAI/src/main/assets/prompts/tools.yml b/plugins/AI-Agent-OpenAI/src/main/assets/prompts/tools.yml new file mode 100644 index 00000000..9b102f83 --- /dev/null +++ b/plugins/AI-Agent-OpenAI/src/main/assets/prompts/tools.yml @@ -0,0 +1,40 @@ +# How the tool list is introduced, and how to call a tool. Exactly one of the two formats is sent: +# native under the function-calling API, text when calls travel in the reply. + +tools: + heading: AVAILABLE TOOLS + +tool_call_format: + # Sent under either format, after the format's own instruction. + no_narration: >- + Do NOT describe the action in prose (e.g. "Okay, I'll open the file…") — narrating does + nothing. + # Its line breaks are sent as written. + native: |- + TOOL CALL FORMAT — the tools above are declared to you: call one through the function-calling + API. A call written into your reply text is NOT read by this system and will not run. + text: + instruction: >- + TOOL CALL FORMAT — to run a tool, emit a single line in EXACTLY this format and nothing + after it: + # The one line Gemini's text format does not have: OpenAI models reach for native calls. + no_native_channel: >- + Do NOT use your provider's native function-calling channel either; a structured tool call is + not read by this system. + only_the_line_runs: The tool only runs when you emit the tool call line itself. + # Its line breaks are sent as written. + examples_heading: |- + FORMAT EXAMPLES (the tool call is the entire reply; the paths are this project's — reuse a path + only when it is the file you actually mean) + # Each renders as "PURPOSE:" followed by the call on its own line. + examples: + - purpose: Report the finished task (the summary goes in "message") + call: '{"tool":"respond","args":{"message":"Renamed count to itemCount."}}' + - purpose: Open a file once you know its path + call: '{"tool":"open_file","args":{"file_path":"{{EXAMPLE_FILE_PATH}}"}}' + - purpose: Find a file by name + call: '{"tool":"search_project","args":{"query":"{{EXAMPLE_FILE_STEM}}"}}' + - purpose: List the project's top-level files (an empty directory means the project root) + call: '{"tool":"list_files","args":{"directory":""}}' + - purpose: Change part of a file (line breaks inside a value MUST be written as \n) + call: '{"tool":"edit_file","args":{"file_path":"{{EXAMPLE_FILE_PATH}}","old_string":"count = 0","new_string":"count = 1"}}' diff --git a/plugins/AI-Agent-OpenAI/src/main/assets/prompts/workflow.yml b/plugins/AI-Agent-OpenAI/src/main/assets/prompts/workflow.yml new file mode 100644 index 00000000..a3a9a6e9 --- /dev/null +++ b/plugins/AI-Agent-OpenAI/src/main/assets/prompts/workflow.yml @@ -0,0 +1,32 @@ +# How the agent goes about building or changing something in the project; neither applies to a +# question, which is answered directly. + +behavior: + heading: BEHAVIOR — for a request to build or change something in this project + items: + - Create complete, production-ready code + - Call tools proactively to build, test, and verify your work + - Read files to understand project structure before making changes + - After each file modification, verify the build compiles + - Generate apps that actually run and work as described + +# Numbered in order when rendered. +workflow: + heading: >- + WORKFLOW — follow these steps only when the user tells you to build or change something in + this project + steps: + - Understand the user's request + - >- + Locate what you need with ONE search_project call — the IDE CONTEXT block below already + names the source, layout and manifest paths + - Create/modify files with complete implementations + - Add dependencies if needed + - Sync gradle and verify compilation + - Run the app to confirm it works + - Report success and what was built + closing: >- + Skip every one of those steps when the user is asking a question, asking for an explanation, + asking about anything other than the open project, or asking you to design, implement or write + code without telling you to add it to their project or app — answer directly instead, with the + complete code in your reply, and offer to add it to the project. diff --git a/plugins/AI-Agent-OpenAI/src/main/kotlin/com/itsaky/androidide/plugins/aiagentopenai/backend/OpenAiBackend.kt b/plugins/AI-Agent-OpenAI/src/main/kotlin/com/itsaky/androidide/plugins/aiagentopenai/backend/OpenAiBackend.kt index 28566943..7c1a1723 100644 --- a/plugins/AI-Agent-OpenAI/src/main/kotlin/com/itsaky/androidide/plugins/aiagentopenai/backend/OpenAiBackend.kt +++ b/plugins/AI-Agent-OpenAI/src/main/kotlin/com/itsaky/androidide/plugins/aiagentopenai/backend/OpenAiBackend.kt @@ -16,6 +16,7 @@ import com.itsaky.androidide.plugins.aiagentopenai.errors.isCredentialProblem import com.itsaky.androidide.plugins.aiagentopenai.logging.LOG_PREFIX import com.itsaky.androidide.plugins.aiagentopenai.preferences.OpenAiPreferences import com.itsaky.androidide.plugins.aiagentopenai.prompt.OpenAiSystemPrompt +import com.itsaky.androidide.plugins.aiagentopenai.prompt.config.OpenAiPromptConfig import com.itsaky.androidide.plugins.aiagentopenai.security.ApiKeyCache import com.itsaky.androidide.plugins.aiagentopenai.settings.BaseUrlPolicy import com.itsaky.androidide.plugins.aiagentopenai.settings.BaseUrlResult @@ -56,9 +57,12 @@ private const val TAG = "$LOG_PREFIX.AgentTrace" * What this class owns is the *conversation*: which model, which turns, what to do when a server * rejects a parameter or answers nothing. Sockets are [OpenAiHttpClient]'s, the decrypted key is * [ApiKeyCache]'s, and the wording of a failure is [OpenAiFailureMessages]'. + * + * @param promptConfig the loaded prompt config, or null while it loads; must return without blocking */ class OpenAiBackend( - private val context: PluginContext + private val context: PluginContext, + private val promptConfig: () -> OpenAiPromptConfig?, ) : HistoryCapableBackend, CancellableBackend, ConfigurableBackend, ToolCallingBackend, EmbeddingBackend { @@ -265,11 +269,23 @@ class OpenAiBackend( } /** - * Written for a large cloud model; see [OpenAiSystemPrompt] for why the wording belongs here - * rather than with the caller. + * Written for a large cloud model; see [OpenAiSystemPrompt] for why the wording belongs here. + * Null until the templates are loaded, which ai-core answers with its default prompt; never + * blocks, since the caller's thread is ai-core's to choose. */ - override fun getSystemPrompt(request: SystemPromptRequest): String = - OpenAiSystemPrompt.build(request) + override fun getSystemPrompt(request: SystemPromptRequest): String? { + val config = promptConfig() + if (config == null) { + context.logger.warn("OpenAiBackend: prompt config not loaded; ai-core default used") + return null + } + return try { + OpenAiSystemPrompt.build(request, config) + } catch (e: IllegalArgumentException) { + context.logger.error("OpenAiBackend: prompt did not render; ai-core default used", e) + null + } + } /** * Room to plan, matching the high-autonomy prompt this backend asks for — or null for a @@ -313,8 +329,15 @@ class OpenAiBackend( val startTime = System.currentTimeMillis() context.logger.info("OpenAiBackend: Generating response for prompt (${prompt.length} chars)") - val messages = OpenAiRequestBuilder.messages(emptyList(), prompt, config.systemPrompt) - val text = requestText(messages, config) + val text = if (OpenAiWebSearch.isRequested(config)) { + if (!BaseUrlPolicy.isOpenAiApi(getBaseUrl())) { + future.complete(LlmResponse.failure(webSearchUnsupported())) + return@launch + } + requestWebSearch(prompt, config) + } else { + requestText(OpenAiRequestBuilder.messages(emptyList(), prompt, config.systemPrompt), config) + } if (text.isBlank()) { future.complete(LlmResponse.failure(failureMessages.of(OpenAiFailure.Failed(null)))) @@ -757,6 +780,27 @@ class OpenAiBackend( return text } + /** + * Searches the web for [query] over the Responses API; the caller has checked the server is OpenAI. + * + * @param query what to look up + * @param config supplies the reporting instructions as its system prompt + * @return the answer with its sources, or "" when the reply carried no text + */ + private fun requestWebSearch(query: String, config: LlmConfig): String = http.post( + url = getBaseUrl() + OpenAiWebSearch.RESPONSES_PATH, + apiKey = readApiKeyOrBlank(), + body = OpenAiWebSearch.body(getModelName(), query, config.systemPrompt), + onAccepted = { credentialFailures.clear() }, + ) { reader -> OpenAiWebSearch.answer(JSONObject(reader.readText())) } + + /** Why a search cannot run against a compatible server, worded for whoever reads the tool result. */ + private fun webSearchUnsupported(): String = try { + context.androidContext.getString(R.string.openai_error_web_search_unsupported, getBaseUrl()) + } catch (e: Exception) { + "Web search needs OpenAI's own API; ${getBaseUrl()} has none." + } + /** * POST [body] to the configured server's chat endpoint with the stored credential. * diff --git a/plugins/AI-Agent-OpenAI/src/main/kotlin/com/itsaky/androidide/plugins/aiagentopenai/backend/OpenAiHttpClient.kt b/plugins/AI-Agent-OpenAI/src/main/kotlin/com/itsaky/androidide/plugins/aiagentopenai/backend/OpenAiHttpClient.kt index f8942bb5..ea2954a6 100644 --- a/plugins/AI-Agent-OpenAI/src/main/kotlin/com/itsaky/androidide/plugins/aiagentopenai/backend/OpenAiHttpClient.kt +++ b/plugins/AI-Agent-OpenAI/src/main/kotlin/com/itsaky/androidide/plugins/aiagentopenai/backend/OpenAiHttpClient.kt @@ -1,8 +1,10 @@ package com.itsaky.androidide.plugins.aiagentopenai.backend import com.itsaky.androidide.plugins.aiagentopenai.errors.OpenAiHttpException +import com.itsaky.androidide.plugins.aiagentopenai.errors.OpenAiTimeoutException import java.io.BufferedReader import java.net.HttpURLConnection +import java.net.SocketTimeoutException import java.net.URL import org.json.JSONObject @@ -28,8 +30,11 @@ internal class OpenAiHttpClient( companion object { private const val CONNECT_TIMEOUT_MS = 15_000 - /** Generation can run for a while, so the read timeout is far longer than the connect. */ - private const val READ_TIMEOUT_MS = 60_000 + /** + * Generation can run for a while, so the read timeout is far longer than the connect. A + * reasoning model such as gpt-5 can think past a minute, longer still around a web search. + */ + private const val READ_TIMEOUT_MS = 180_000 } /** @@ -44,6 +49,7 @@ internal class OpenAiHttpClient( * @param onAccepted called once the status line says 2xx, before a byte of the body is read * @return whatever [readResponse] produced * @throws OpenAiHttpException on a non-2xx answer, carrying the server's error body + * @throws OpenAiTimeoutException when the server accepted the request, then went silent */ fun post( url: String, @@ -64,9 +70,14 @@ internal class OpenAiHttpClient( onConnected(conn) try { conn.outputStream.use { it.write(body.toString().toByteArray(Charsets.UTF_8)) } - conn.failIfNotOk() - onAccepted() - conn.inputStream.bufferedReader().use(readResponse) + try { + conn.failIfNotOk() + onAccepted() + conn.inputStream.bufferedReader().use(readResponse) + } catch (e: SocketTimeoutException) { + // The body was sent, so the connection worked; only the answer was slow. + throw OpenAiTimeoutException(readTimeoutMs, e) + } } finally { conn.disconnect() } diff --git a/plugins/AI-Agent-OpenAI/src/main/kotlin/com/itsaky/androidide/plugins/aiagentopenai/backend/OpenAiRequestBuilder.kt b/plugins/AI-Agent-OpenAI/src/main/kotlin/com/itsaky/androidide/plugins/aiagentopenai/backend/OpenAiRequestBuilder.kt index 57d018d7..f90647bd 100644 --- a/plugins/AI-Agent-OpenAI/src/main/kotlin/com/itsaky/androidide/plugins/aiagentopenai/backend/OpenAiRequestBuilder.kt +++ b/plugins/AI-Agent-OpenAI/src/main/kotlin/com/itsaky/androidide/plugins/aiagentopenai/backend/OpenAiRequestBuilder.kt @@ -59,7 +59,7 @@ internal object OpenAiRequestBuilder { * * @param model the model id to request * @param stream true to ask for the SSE token stream - * @param config supplies the token cap and temperature + * @param config supplies the token cap, temperature and any tool the turn must call * @param tuning decides which optional parameters are sent at all * @param tools the tools to declare; omitted from the body when empty * @return the request JSON @@ -81,6 +81,8 @@ internal object OpenAiRequestBuilder { // a file whose contents carry quotes or newlines can no longer break the call (ADFA-5410). if (tools.isNotEmpty()) { body.put("tools", OpenAiToolProtocol.toolsArray(tools)) + val choice = OpenAiToolProtocol.requiredToolChoice(config, tools) + if (choice != null && tuning.sendToolChoice) body.put(RequestTuning.TOOL_CHOICE, choice) } if (config.maxTokens > 0) { diff --git a/plugins/AI-Agent-OpenAI/src/main/kotlin/com/itsaky/androidide/plugins/aiagentopenai/backend/OpenAiToolProtocol.kt b/plugins/AI-Agent-OpenAI/src/main/kotlin/com/itsaky/androidide/plugins/aiagentopenai/backend/OpenAiToolProtocol.kt index 30d569df..968e8bbd 100644 --- a/plugins/AI-Agent-OpenAI/src/main/kotlin/com/itsaky/androidide/plugins/aiagentopenai/backend/OpenAiToolProtocol.kt +++ b/plugins/AI-Agent-OpenAI/src/main/kotlin/com/itsaky/androidide/plugins/aiagentopenai/backend/OpenAiToolProtocol.kt @@ -1,5 +1,6 @@ package com.itsaky.androidide.plugins.aiagentopenai.backend +import com.itsaky.androidide.plugins.services.LlmInferenceService.LlmConfig import com.itsaky.androidide.plugins.services.LlmInferenceService.ToolCallRequest import com.itsaky.androidide.plugins.services.LlmInferenceService.ToolDefinition import org.json.JSONArray @@ -19,6 +20,24 @@ internal object OpenAiToolProtocol { */ private const val MAX_SCHEMA_DEPTH = 12 + /** The key ai-core's `WebAccess.EXTRA_PARAM_REQUIRED_TOOL` sets; the same literal on both sides. */ + const val EXTRA_PARAM_REQUIRED_TOOL = "required_tool" + + /** + * The `tool_choice` that makes the model call [config]'s required tool this turn. + * + * @param config the turn's config; its `required_tool` extra names the tool. + * @param tools the tools the request declares. + * @return the choice, or null when none is required or the required one is not declared. + */ + fun requiredToolChoice(config: LlmConfig, tools: List): JSONObject? { + val name = config.extraParams?.get(EXTRA_PARAM_REQUIRED_TOOL) as? String ?: return null + if (tools.none { it.name == name }) return null + return JSONObject() + .put("type", "function") + .put("function", JSONObject().put("name", name)) + } + /** * One `tool_calls` fragment as it arrives on the stream. * diff --git a/plugins/AI-Agent-OpenAI/src/main/kotlin/com/itsaky/androidide/plugins/aiagentopenai/backend/OpenAiWebSearch.kt b/plugins/AI-Agent-OpenAI/src/main/kotlin/com/itsaky/androidide/plugins/aiagentopenai/backend/OpenAiWebSearch.kt new file mode 100644 index 00000000..6351cdb5 --- /dev/null +++ b/plugins/AI-Agent-OpenAI/src/main/kotlin/com/itsaky/androidide/plugins/aiagentopenai/backend/OpenAiWebSearch.kt @@ -0,0 +1,78 @@ +package com.itsaky.androidide.plugins.aiagentopenai.backend + +import com.itsaky.androidide.plugins.aiagentopenai.errors.OpenAiReplyException +import com.itsaky.androidide.plugins.services.LlmInferenceService.LlmConfig +import org.json.JSONArray +import org.json.JSONObject + +/** + * OpenAI's hosted web search for the agent's `web_search` tool, which asks for it through + * [LlmConfig.extraParams]. Spoken over the Responses API: `chat/completions` searches only with the + * dedicated `*-search-api` models, and no compatible server (Ollama, LM Studio) searches at all. + */ +internal object OpenAiWebSearch { + + /** The key ai-core's `WebAccess.EXTRA_PARAM_WEB_SEARCH` sets; the same literal on both sides. */ + const val EXTRA_PARAM_WEB_SEARCH = "web_search" + + /** The Responses API endpoint, under the same base URL as `chat/completions`. */ + const val RESPONSES_PATH = "/responses" + + /** @return whether [config] asks for an answer grounded in a web search. */ + fun isRequested(config: LlmConfig): Boolean = + config.extraParams?.get(EXTRA_PARAM_WEB_SEARCH) == true + + /** + * A Responses request that searches for [query]. + * + * No temperature and no output cap: reasoning models refuse the first, and spend the second + * thinking, which ends a search with no answer at all. + * + * @param model the model id to search with + * @param query what to look up; the request's input + * @param instructions how to report, or null for the model's default + * @return the request JSON + */ + fun body(model: String, query: String, instructions: String?): JSONObject { + val body = JSONObject() + .put("model", model) + .put("input", query) + .put("tools", JSONArray().put(JSONObject().put("type", "web_search"))) + instructions?.takeIf { it.isNotBlank() }?.let { body.put("instructions", it) } + return body + } + + /** + * The answer text with its cited sources listed under it. + * + * @param response the parsed Responses API reply + * @return the text of every `output_text` part, followed by a "Sources:" list of its + * `url_citation` annotations; "" when the reply carried no text + * @throws OpenAiReplyException when a 2xx reply reports an `error`, as a `failed` search does + */ + fun answer(response: JSONObject): String { + // Thrown rather than read as "no text", which would hide a refused key or a spent quota. + response.optJSONObject("error")?.let { throw OpenAiReplyException(response.toString()) } + val text = StringBuilder() + val sources = LinkedHashSet() + val output = response.optJSONArray("output") ?: JSONArray() + for (i in 0 until output.length()) { + val item = output.optJSONObject(i)?.takeIf { it.optString("type") == "message" } ?: continue + val content = item.optJSONArray("content") ?: continue + for (j in 0 until content.length()) { + val part = content.optJSONObject(j)?.takeIf { it.optString("type") == "output_text" } ?: continue + text.append(part.optString("text")) + val annotations = part.optJSONArray("annotations") ?: continue + for (k in 0 until annotations.length()) { + val note = annotations.optJSONObject(k)?.takeIf { it.optString("type") == "url_citation" } ?: continue + val url = note.optString("url").takeIf { it.isNotBlank() } ?: continue + val title = note.optString("title").takeIf { it.isNotBlank() } + sources += if (title == null) "- $url" else "- $title: $url" + } + } + } + val answer = text.toString().trim() + if (answer.isEmpty() || sources.isEmpty()) return answer + return answer + "\n\nSources:\n" + sources.joinToString("\n") + } +} diff --git a/plugins/AI-Agent-OpenAI/src/main/kotlin/com/itsaky/androidide/plugins/aiagentopenai/backend/RequestTuning.kt b/plugins/AI-Agent-OpenAI/src/main/kotlin/com/itsaky/androidide/plugins/aiagentopenai/backend/RequestTuning.kt index a4463844..6f817fcf 100644 --- a/plugins/AI-Agent-OpenAI/src/main/kotlin/com/itsaky/androidide/plugins/aiagentopenai/backend/RequestTuning.kt +++ b/plugins/AI-Agent-OpenAI/src/main/kotlin/com/itsaky/androidide/plugins/aiagentopenai/backend/RequestTuning.kt @@ -11,10 +11,12 @@ import com.itsaky.androidide.plugins.aiagentopenai.errors.OpenAiErrorFormatter * * @param tokenParam the JSON key carrying the output-token cap * @param sendTemperature false to omit `temperature` entirely + * @param sendToolChoice false to omit `tool_choice`, which some compatible servers do not take */ internal data class RequestTuning( val tokenParam: String, val sendTemperature: Boolean, + val sendToolChoice: Boolean = true, ) { /** @@ -28,6 +30,7 @@ internal data class RequestTuning( */ fun without(param: String): RequestTuning? = when (param) { TEMPERATURE -> if (sendTemperature) copy(sendTemperature = false) else null + TOOL_CHOICE -> if (sendToolChoice) copy(sendToolChoice = false) else null MAX_COMPLETION_TOKENS -> if (tokenParam == MAX_COMPLETION_TOKENS) copy(tokenParam = MAX_TOKENS) else null MAX_TOKENS -> @@ -39,6 +42,7 @@ internal data class RequestTuning( const val TEMPERATURE = "temperature" const val MAX_TOKENS = "max_tokens" const val MAX_COMPLETION_TOKENS = "max_completion_tokens" + const val TOOL_CHOICE = "tool_choice" /** * Model id prefixes whose models are reasoning models on `chat/completions`. @@ -91,6 +95,8 @@ internal object UnsupportedParameter { RequestTuning.MAX_COMPLETION_TOKENS, RequestTuning.MAX_TOKENS, RequestTuning.TEMPERATURE, + // Before the tools detector sees it: refusing a forced call is no reason to drop every tool. + RequestTuning.TOOL_CHOICE, ) /** diff --git a/plugins/AI-Agent-OpenAI/src/main/kotlin/com/itsaky/androidide/plugins/aiagentopenai/errors/OpenAiErrorFormatter.kt b/plugins/AI-Agent-OpenAI/src/main/kotlin/com/itsaky/androidide/plugins/aiagentopenai/errors/OpenAiErrorFormatter.kt index 54363f43..d05e6615 100644 --- a/plugins/AI-Agent-OpenAI/src/main/kotlin/com/itsaky/androidide/plugins/aiagentopenai/errors/OpenAiErrorFormatter.kt +++ b/plugins/AI-Agent-OpenAI/src/main/kotlin/com/itsaky/androidide/plugins/aiagentopenai/errors/OpenAiErrorFormatter.kt @@ -69,6 +69,13 @@ sealed interface OpenAiFailure { /** No response and the server was OpenAI itself, i.e. the device has no route out. */ data object Unreachable : OpenAiFailure + /** + * The server took the request, then sent nothing for [seconds]: a slow model, not a network fault. + * + * @param seconds how long the read waited + */ + data class TimedOut(val seconds: Int) : OpenAiFailure + /** * The server streamed successfully but produced no reply text. * @@ -128,6 +135,7 @@ internal enum class CredentialFailure(val tag: String, @get:StringRes val messag is OpenAiFailure.Unexpected, OpenAiFailure.ServerNotRunning, OpenAiFailure.Unreachable, + is OpenAiFailure.TimedOut, is OpenAiFailure.EmptyReply, OpenAiFailure.ReasoningOnly, OpenAiFailure.TruncatedBeforeReply, @@ -205,6 +213,9 @@ object OpenAiErrorFormatter { status == 429 && parsed.mentionsBilling() -> OpenAiFailure.BillingRequired + // 402 is how compatible gateways say "no credit"; the code is how a 2xx error reply does. + status == 402 || parsed.apiCode == "insufficient_quota" -> OpenAiFailure.BillingRequired + status == 429 || parsed.apiCode == "rate_limit_exceeded" -> OpenAiFailure.QuotaExceeded @@ -220,6 +231,9 @@ object OpenAiErrorFormatter { status != null -> OpenAiFailure.Unexpected(status, safeReason(parsed, error)) + // Checked before IOException, which it is: the request arrived and the answer was slow. + error is OpenAiTimeoutException -> OpenAiFailure.TimedOut(error.timeoutMs / 1000) + // No status at all: the request never got an answer. error is IOException -> if (isOpenAiHost) OpenAiFailure.Unreachable else OpenAiFailure.ServerNotRunning diff --git a/plugins/AI-Agent-OpenAI/src/main/kotlin/com/itsaky/androidide/plugins/aiagentopenai/errors/OpenAiFailureMessages.kt b/plugins/AI-Agent-OpenAI/src/main/kotlin/com/itsaky/androidide/plugins/aiagentopenai/errors/OpenAiFailureMessages.kt index b0b315cc..ea780d22 100644 --- a/plugins/AI-Agent-OpenAI/src/main/kotlin/com/itsaky/androidide/plugins/aiagentopenai/errors/OpenAiFailureMessages.kt +++ b/plugins/AI-Agent-OpenAI/src/main/kotlin/com/itsaky/androidide/plugins/aiagentopenai/errors/OpenAiFailureMessages.kt @@ -70,6 +70,9 @@ internal class OpenAiFailureMessages( OpenAiFailure.Unreachable -> resources.getString(R.string.openai_error_unreachable) + is OpenAiFailure.TimedOut -> + resources.getString(R.string.openai_error_timed_out, failure.seconds) + is OpenAiFailure.Failed -> failure.reason?.let { resources.getString(R.string.openai_error_failed_reason, it) } ?: resources.getString(R.string.openai_error_failed) diff --git a/plugins/AI-Agent-OpenAI/src/main/kotlin/com/itsaky/androidide/plugins/aiagentopenai/errors/OpenAiReplyException.kt b/plugins/AI-Agent-OpenAI/src/main/kotlin/com/itsaky/androidide/plugins/aiagentopenai/errors/OpenAiReplyException.kt new file mode 100644 index 00000000..b318b83e --- /dev/null +++ b/plugins/AI-Agent-OpenAI/src/main/kotlin/com/itsaky/androidide/plugins/aiagentopenai/errors/OpenAiReplyException.kt @@ -0,0 +1,11 @@ +package com.itsaky.androidide.plugins.aiagentopenai.errors + +/** + * A 2xx reply whose body reports an `error` object instead of an answer. + * + * Carries the body in its message so [OpenAiErrorFormatter.classify] reads the server's `code` and + * `message` from it exactly as it does for an [OpenAiHttpException]; never shown unfiltered. + * + * @param body the server's reply body + */ +class OpenAiReplyException(val body: String) : Exception("OpenAI reply error: $body") diff --git a/plugins/AI-Agent-OpenAI/src/main/kotlin/com/itsaky/androidide/plugins/aiagentopenai/errors/OpenAiTimeoutException.kt b/plugins/AI-Agent-OpenAI/src/main/kotlin/com/itsaky/androidide/plugins/aiagentopenai/errors/OpenAiTimeoutException.kt new file mode 100644 index 00000000..09bdaae1 --- /dev/null +++ b/plugins/AI-Agent-OpenAI/src/main/kotlin/com/itsaky/androidide/plugins/aiagentopenai/errors/OpenAiTimeoutException.kt @@ -0,0 +1,17 @@ +package com.itsaky.androidide.plugins.aiagentopenai.errors + +import java.io.IOException + +/** + * The request reached the server, which then sent nothing for [timeoutMs]. + * + * Its own type because the connection worked: a reasoning model, or one running a web search, was + * still thinking, and reporting that as "could not reach" sends the user to check a network that is fine. + * + * @param timeoutMs how long the read waited + * @param cause the socket's own timeout + */ +class OpenAiTimeoutException( + val timeoutMs: Int, + cause: Throwable, +) : IOException("OpenAI sent nothing for ${timeoutMs}ms", cause) diff --git a/plugins/AI-Agent-OpenAI/src/main/kotlin/com/itsaky/androidide/plugins/aiagentopenai/plugin/OpenAiPlugin.kt b/plugins/AI-Agent-OpenAI/src/main/kotlin/com/itsaky/androidide/plugins/aiagentopenai/plugin/OpenAiPlugin.kt index 6fc0e457..2a6e7882 100644 --- a/plugins/AI-Agent-OpenAI/src/main/kotlin/com/itsaky/androidide/plugins/aiagentopenai/plugin/OpenAiPlugin.kt +++ b/plugins/AI-Agent-OpenAI/src/main/kotlin/com/itsaky/androidide/plugins/aiagentopenai/plugin/OpenAiPlugin.kt @@ -3,12 +3,22 @@ package com.itsaky.androidide.plugins.aiagentopenai.plugin import com.itsaky.androidide.plugins.IPlugin import com.itsaky.androidide.plugins.PluginContext import com.itsaky.androidide.plugins.PluginLifecycleListener +import com.itsaky.androidide.plugins.ai.prompt.AssetPromptConfigSource import com.itsaky.androidide.plugins.aiagentopenai.backend.OpenAiBackend +import com.itsaky.androidide.plugins.aiagentopenai.prompt.OpenAiSystemPrompt +import com.itsaky.androidide.plugins.aiagentopenai.prompt.config.OpenAiPromptConfig +import com.itsaky.androidide.plugins.aiagentopenai.prompt.config.sharedPromptConfig import com.itsaky.androidide.plugins.extensions.DocumentationExtension import com.itsaky.androidide.plugins.extensions.PluginTooltipButton import com.itsaky.androidide.plugins.extensions.PluginTooltipEntry import com.itsaky.androidide.plugins.services.LlmInferenceService import com.itsaky.androidide.plugins.services.SharedServices +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.cancel /** * Registers the OpenAI-compatible backend with AI Core's inference router. @@ -25,6 +35,9 @@ class OpenAiPlugin : IPlugin, DocumentationExtension { /** True once [backend] is registered with the router, so re-registration is idempotent. */ @Volatile private var registered = false + /** Runs the prompt-config load while the plugin is active; cancelled on deactivation. */ + @Volatile private var configScope: CoroutineScope? = null + companion object { const val PLUGIN_ID = "com.itsaky.androidide.plugins.aiagentopenai" @@ -103,8 +116,9 @@ class OpenAiPlugin : IPlugin, DocumentationExtension { return try { // A half-failed activation can leave a backend behind; keep at most one live. releaseBackend() + preloadPromptConfig() - val openAi = OpenAiBackend(context) + val openAi = OpenAiBackend(context, sharedPromptConfig::configIfLoaded) backend = openAi activeBackend = openAi @@ -165,6 +179,45 @@ class OpenAiPlugin : IPlugin, DocumentationExtension { null } + /** Reads and validates the prompt config now, so building a prompt does no disk I/O. */ + @OptIn(ExperimentalCoroutinesApi::class) + private fun preloadPromptConfig() { + releasePromptConfig() + val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default) + configScope = scope + val source = AssetPromptConfigSource(context.androidContext.assets) + val load = sharedPromptConfig.preload(scope, source) + load.invokeOnCompletion { error -> + when (error) { + null -> reportLoadedConfig(load.getCompleted()) + is CancellationException -> Unit + else -> context.logger.error( + "OpenAiPlugin: prompt config failed to load; ai-core's default prompt is sent instead", + error, + ) + } + } + } + + /** + * Logs that the config loaded, and any name typo its layout would hit at render time. + * + * @param config the config just loaded. + */ + private fun reportLoadedConfig(config: OpenAiPromptConfig) { + context.logger.info("OpenAiPlugin: loaded prompt config with ${config.rules.size} rule groups") + for (problem in OpenAiSystemPrompt.problems(config)) { + context.logger.warn("OpenAiPlugin: $problem; ai-core's default prompt is sent instead") + } + } + + /** Drops the cached config and stops a load still in flight. Idempotent. */ + private fun releasePromptConfig() { + sharedPromptConfig.clear() + configScope?.cancel() + configScope = null + } + override fun deactivate(): Boolean { context.logger.info("OpenAiPlugin: Deactivating plugin") @@ -180,6 +233,7 @@ class OpenAiPlugin : IPlugin, DocumentationExtension { // A disabled plugin must not keep the decrypted key on the host heap. releaseBackend() + releasePromptConfig() true } catch (e: Exception) { @@ -206,6 +260,7 @@ class OpenAiPlugin : IPlugin, DocumentationExtension { runCatching { context.removePluginLifecycleListener(aiCoreLifecycle) } releaseBackend() + releasePromptConfig() pluginContext = null context.logger.info("OpenAiPlugin: Released OpenAI backend") } diff --git a/plugins/AI-Agent-OpenAI/src/main/kotlin/com/itsaky/androidide/plugins/aiagentopenai/prompt/OpenAiPromptVariables.kt b/plugins/AI-Agent-OpenAI/src/main/kotlin/com/itsaky/androidide/plugins/aiagentopenai/prompt/OpenAiPromptVariables.kt new file mode 100644 index 00000000..dbf0cbe0 --- /dev/null +++ b/plugins/AI-Agent-OpenAI/src/main/kotlin/com/itsaky/androidide/plugins/aiagentopenai/prompt/OpenAiPromptVariables.kt @@ -0,0 +1,110 @@ +package com.itsaky.androidide.plugins.aiagentopenai.prompt + +import com.itsaky.androidide.plugins.aiagentopenai.prompt.config.OpenAiPromptConfig +import com.itsaky.androidide.plugins.services.LlmInferenceService.SystemPromptRequest + +/** + * The values the prompt config is rendered with: its texts, named by YAML path, and the request's. + * Every key is always present, empty when it does not apply, so a name missing here is a typo in + * a file and fails the render instead of silently dropping text. + */ +internal object OpenAiPromptVariables { + + // Config texts: `identity` is IDENTITY, `scope.heading` is SCOPE_HEADING, and so on. + const val IDENTITY = "IDENTITY" + const val SCOPE_HEADING = "SCOPE_HEADING" + const val SCOPE_ITEMS = "SCOPE_ITEMS" + const val RULES = "RULES" + const val HEADING = "HEADING" + const val ITEMS = "ITEMS" + const val TEXT = "TEXT" + const val BEHAVIOR_HEADING = "BEHAVIOR_HEADING" + const val BEHAVIOR_ITEMS = "BEHAVIOR_ITEMS" + const val WORKFLOW_HEADING = "WORKFLOW_HEADING" + const val WORKFLOW_STEPS = "WORKFLOW_STEPS" + const val WORKFLOW_CLOSING = "WORKFLOW_CLOSING" + const val TOOLS_HEADING = "TOOLS_HEADING" + const val TOOL_CALL_FORMAT_NO_NARRATION = "TOOL_CALL_FORMAT_NO_NARRATION" + const val TOOL_CALL_FORMAT_NATIVE = "TOOL_CALL_FORMAT_NATIVE" + const val TOOL_CALL_FORMAT_TEXT_INSTRUCTION = "TOOL_CALL_FORMAT_TEXT_INSTRUCTION" + const val TOOL_CALL_FORMAT_TEXT_NO_NATIVE_CHANNEL = "TOOL_CALL_FORMAT_TEXT_NO_NATIVE_CHANNEL" + const val TOOL_CALL_FORMAT_TEXT_ONLY_THE_LINE_RUNS = "TOOL_CALL_FORMAT_TEXT_ONLY_THE_LINE_RUNS" + const val TOOL_CALL_FORMAT_TEXT_EXAMPLES_HEADING = "TOOL_CALL_FORMAT_TEXT_EXAMPLES_HEADING" + const val TOOL_CALL_FORMAT_TEXT_EXAMPLES = "TOOL_CALL_FORMAT_TEXT_EXAMPLES" + const val PURPOSE = "PURPOSE" + const val CALL = "CALL" + + /** A workflow step's 1-based position, inside `{{#WORKFLOW_STEPS}}`. */ + const val NUMBER = "NUMBER" + + /** The tools the request offers, each with [NAME] and [DESCRIPTION]. */ + const val TOOLS = "TOOLS" + + /** The tool-call envelope; null when calls travel through the function-calling API. */ + const val TOOL_CALL_SYNTAX = "TOOL_CALL_SYNTAX" + + /** Whether calls travel through the function-calling API rather than the reply text. */ + const val NATIVE_TOOL_CALLS = "NATIVE_TOOL_CALLS" + + /** A real project path to show in examples. */ + const val EXAMPLE_FILE_PATH = "EXAMPLE_FILE_PATH" + + /** [EXAMPLE_FILE_PATH]'s file name without folder or extension, for search examples. */ + const val EXAMPLE_FILE_STEM = "EXAMPLE_FILE_STEM" + + /** A tool's name, inside `{{#TOOLS}}`. */ + const val NAME = "NAME" + + /** A tool's description, inside `{{#TOOLS}}`. */ + const val DESCRIPTION = "DESCRIPTION" + + /** Path used in examples when the caller names none, so they still show a concrete shape. */ + const val FALLBACK_EXAMPLE_PATH = "app/src/main/java/com/example/MainActivity.kt" + + /** + * Collects every value `layout.system_prompt` may use. + * + * @param config the loaded prompt config. + * @param request the tool list, envelope syntax and example path to describe. + * @return the values, keyed by name. + */ + fun collect(config: OpenAiPromptConfig, request: SystemPromptRequest): Map { + val examplePath = request.exampleFilePath ?: FALLBACK_EXAMPLE_PATH + val format = config.toolCallFormat + return mapOf( + IDENTITY to config.identity, + SCOPE_HEADING to config.scope.heading, + SCOPE_ITEMS to config.scope.items.map { mapOf(TEXT to it) }, + RULES to config.rules.map { group -> + mapOf(HEADING to group.heading, ITEMS to group.items.map { mapOf(TEXT to it) }) + }, + BEHAVIOR_HEADING to config.behavior.heading, + BEHAVIOR_ITEMS to config.behavior.items.map { mapOf(TEXT to it) }, + WORKFLOW_HEADING to config.workflow.heading, + WORKFLOW_STEPS to config.workflow.steps.mapIndexed { index, step -> + mapOf(NUMBER to (index + 1).toString(), TEXT to step) + }, + WORKFLOW_CLOSING to config.workflow.closing, + TOOLS_HEADING to config.tools.heading, + TOOL_CALL_FORMAT_NO_NARRATION to format.noNarration, + TOOL_CALL_FORMAT_NATIVE to format.native, + TOOL_CALL_FORMAT_TEXT_INSTRUCTION to format.text.instruction, + TOOL_CALL_FORMAT_TEXT_NO_NATIVE_CHANNEL to format.text.noNativeChannel, + TOOL_CALL_FORMAT_TEXT_ONLY_THE_LINE_RUNS to format.text.onlyTheLineRuns, + TOOL_CALL_FORMAT_TEXT_EXAMPLES_HEADING to format.text.examplesHeading, + TOOL_CALL_FORMAT_TEXT_EXAMPLES to format.text.examples.map { + mapOf(PURPOSE to it.purpose, CALL to it.call) + }, + // Plain Strings, so a contributed tool's description is never rendered as a template. + TOOLS to request.tools.map { mapOf(NAME to it.name, DESCRIPTION to it.description) }, + EXAMPLE_FILE_PATH to examplePath, + // A dotfile's name is all extension, so its stem would be an empty search. + EXAMPLE_FILE_STEM to examplePath.substringAfterLast('/').let { name -> + name.substringBeforeLast('.').ifEmpty { name } + }, + // Null syntax: calls arrive via the function-calling API, not the text (ADFA-5410). + TOOL_CALL_SYNTAX to request.toolCallSyntax, + NATIVE_TOOL_CALLS to (request.toolCallSyntax == null), + ) + } +} diff --git a/plugins/AI-Agent-OpenAI/src/main/kotlin/com/itsaky/androidide/plugins/aiagentopenai/prompt/OpenAiSystemPrompt.kt b/plugins/AI-Agent-OpenAI/src/main/kotlin/com/itsaky/androidide/plugins/aiagentopenai/prompt/OpenAiSystemPrompt.kt index 24b8102e..205dd8a8 100644 --- a/plugins/AI-Agent-OpenAI/src/main/kotlin/com/itsaky/androidide/plugins/aiagentopenai/prompt/OpenAiSystemPrompt.kt +++ b/plugins/AI-Agent-OpenAI/src/main/kotlin/com/itsaky/androidide/plugins/aiagentopenai/prompt/OpenAiSystemPrompt.kt @@ -1,110 +1,53 @@ package com.itsaky.androidide.plugins.aiagentopenai.prompt +import com.itsaky.androidide.plugins.ai.prompt.PromptTemplateEngine +import com.itsaky.androidide.plugins.aiagentopenai.prompt.config.OpenAiPromptConfig import com.itsaky.androidide.plugins.services.LlmInferenceService.SystemPromptRequest +import com.itsaky.androidide.plugins.services.LlmInferenceService.ToolDefinition /** - * The system prompt this backend asks for. - * - * Written for a large cloud model: it states a goal and a workflow and trusts the model to plan - * within them, where a small on-device model needs each step spelled out. That difference is a - * property of the model, so the prompt lives with the backend that talks to it. - * - * Model-facing text, so it stays in Kotlin rather than `strings.xml` — it is never shown to the - * user, must not be translated, and is asserted on in unit tests. - * - * Pure and free of Android types, so it is unit-testable without a device or a network. + * The system prompt this backend asks for: `layout.yml`'s `system_prompt`, rendered in one pass. + * Written for a large cloud model, so the wording lives with the backend that talks to it; knows + * no wording itself, which is the config's. Pure and thread-safe. Mirrors Gemini's: edit together. */ internal object OpenAiSystemPrompt { /** - * Path used in the examples when the caller names none, so they still show a concrete shape. + * Builds the prompt; the envelope and its examples appear only when the caller parses text. + * + * @param request the tool list, envelope syntax and example path to describe. + * @param config the loaded prompt config. + * @return the system prompt, without the caller's IDE-context block. */ - private const val FALLBACK_EXAMPLE_PATH = "app/src/main/java/com/example/MainActivity.kt" - - /** How to call a tool when the caller takes calls through the provider's own API. */ - private val NATIVE_CALL_FORMAT = """ - TOOL CALL FORMAT — the tools above are declared to you: call one through the function-calling - API. A call written into your reply text is NOT read by this system and will not run. - Do NOT describe the action in prose (e.g. "Okay, I'll open the file…") — narrating does nothing. - """.trimIndent() + fun build(request: SystemPromptRequest, config: OpenAiPromptConfig): String = + PromptTemplateEngine.render(config.layout.systemPrompt, OpenAiPromptVariables.collect(config, request)) + .trimEnd() /** - * Builds the prompt for [request]. - * - * [SystemPromptRequest.toolCallSyntax] is reproduced verbatim — a paraphrase would produce - * replies nothing reads — and a null one means the caller reads calls off the provider's own - * function-calling API, so [NATIVE_CALL_FORMAT] replaces the envelope rather than joining it. + * Renders requests that open and close every section, to catch a name typo. * - * @return the system prompt, without the caller's IDE-context block + * @param config the loaded prompt config. + * @return one message per distinct failure; empty when every request renders. */ - fun build(request: SystemPromptRequest): String { - val toolDescriptions = request.tools.joinToString("\n") { "- ${it.name}: ${it.description}" } - val examplePath = request.exampleFilePath ?: FALLBACK_EXAMPLE_PATH - val exampleStem = examplePath.substringAfterLast('/').substringBeforeLast('.') - - val head = """ - You are a senior Android developer integrated into CodeOnTheGo. Your goal is to build complete, working Android apps from user descriptions. - - AVAILABLE TOOLS: - $toolDescriptions - - BEHAVIOR: - - Create complete, production-ready code - - Call tools proactively to build, test, and verify your work - - Read files to understand project structure before making changes - - After each file modification, verify the build compiles - - Generate apps that actually run and work as described - - RULES: - - Emit ONE tool call per reply, then stop and wait. Do NOT plan a batch: a tool whose arguments depend on another tool's result (editing a file you just searched for) cannot use a result you have not received yet. - - To locate a file, call search_project ONCE with its name — it searches the whole project. Never walk the tree with repeated list_files calls; you have a limited number of turns and each level wastes one. - - Renaming a symbol everywhere in a file is ONE edit_file with replace_all set to true and old_string set to just the symbol — not one edit per line. - - To change an existing file, use edit_file (find/replace an exact snippet), not update_file — a whole-file rewrite gets truncated before it reaches disk. - - Before edit_file, read the exact file you are about to edit with read_file, and copy old_string byte-for-byte from that output, including indentation. Never edit a path you have not confirmed exists. - - old_string must be the text currently in the file and new_string what it should become. If they are identical the edit is rejected. - - Never fabricate tool output. Emit a tool call, then wait for the real result before continuing. - - Never write "User:", "Assistant:", a block, or a ```tool_response fence — the system supplies real results. Any tool output you write yourself is a hallucination and will be ignored. - - 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, either give a short summary with no tool call, or end with a single respond call carrying that summary in its "message" — never an empty respond. - """.trimIndent() - - val workflow = """ - WORKFLOW: - 1. Understand the user's request - 2. Locate what you need with ONE search_project call — the IDE CONTEXT block above already names the source, layout and manifest paths - 3. Create/modify files with complete implementations - 4. Add dependencies if needed - 5. Sync gradle and verify compilation - 6. Run the app to confirm it works - 7. Report success and what was built - """.trimIndent() - - // Null syntax means the caller reads calls off the function-calling API instead. Saying so - // is what stops the model writing one as text, where nothing would run it (ADFA-5410). - val syntax = request.toolCallSyntax ?: return listOf(head, NATIVE_CALL_FORMAT, workflow) - .joinToString("\n\n") - - val callFormat = """ - TOOL CALL FORMAT — to run a tool, emit a single line in EXACTLY this format and nothing after it: - $syntax - Do NOT describe the action in prose (e.g. "Okay, I'll open the file…") — narrating does nothing. - Do NOT use your provider's native function-calling channel either; a structured tool call is not read by this system. - The tool only runs when you emit the tool call line itself. - - FORMAT EXAMPLES (the tool call is the entire reply; the paths are this project's — reuse a path - only when it is the file you actually mean): - Report the finished task (the summary goes in "message"): - {"tool":"respond","args":{"message":"Renamed count to itemCount."}} - Open a file once you know its path: - {"tool":"open_file","args":{"file_path":"$examplePath"}} - Find a file by name: - {"tool":"search_project","args":{"query":"$exampleStem"}} - List the project's top-level files (an empty directory means the project root): - {"tool":"list_files","args":{"directory":""}} - Change part of a file (line breaks inside a value MUST be written as \n): - {"tool":"edit_file","args":{"file_path":"$examplePath","old_string":"count = 0","new_string":"count = 1"}} - """.trimIndent() - - return head + "\n\n" + callFormat + "\n\n" + workflow + fun problems(config: OpenAiPromptConfig): List = + CHECK_REQUESTS.mapNotNull { request -> + try { + build(request, config) + null + } catch (e: IllegalArgumentException) { + e.message + } + }.distinct() + + /** Text and native calling, two tools and none, a real path and the fallback. */ + private val CHECK_REQUESTS: List = run { + val tools = listOf( + ToolDefinition("read_file", "Read a file.", emptyMap()), + ToolDefinition("respond", "Reply.", emptyMap()), + ) + listOf( + SystemPromptRequest(tools, "…", "app/Main.kt"), + SystemPromptRequest(emptyList(), null, null), + ) } } diff --git a/plugins/AI-Agent-OpenAI/src/main/kotlin/com/itsaky/androidide/plugins/aiagentopenai/prompt/config/OpenAiPromptConfig.kt b/plugins/AI-Agent-OpenAI/src/main/kotlin/com/itsaky/androidide/plugins/aiagentopenai/prompt/config/OpenAiPromptConfig.kt new file mode 100644 index 00000000..ebd8832b --- /dev/null +++ b/plugins/AI-Agent-OpenAI/src/main/kotlin/com/itsaky/androidide/plugins/aiagentopenai/prompt/config/OpenAiPromptConfig.kt @@ -0,0 +1,92 @@ +package com.itsaky.androidide.plugins.aiagentopenai.prompt.config + +import com.itsaky.androidide.plugins.ai.prompt.PromptConfigLoader +import com.itsaky.androidide.plugins.ai.prompt.PromptText + + +/** + * OpenAI's system prompt as `assets/prompts/` declares it: the wording, and the layout that + * arranges it. Loaded by [PromptConfigLoader]; immutable, so one instance serves every request. + * + * @property identity who the agent is. + * @property scope what the agent will answer. + * @property rules the rules, highest priority first. + * @property behavior how to go about building or changing something. + * @property workflow the steps of such a task, in order. + * @property tools the wording around the tool list. + * @property toolCallFormat how to call a tool, natively or as text. + * @property layout where each text goes. + */ +data class OpenAiPromptConfig( + val identity: PromptText, + val scope: Section, + val rules: List, + val behavior: Section, + val workflow: Workflow, + val tools: Tools, + val toolCallFormat: ToolCallFormat, + val layout: Layout, +) { + + /** + * A heading and the lines under it. + * + * @property heading what the lines are about. + * @property items one sentence each. + */ + data class Section(val heading: PromptText, val items: List) + + /** + * One priority's rules. + * + * @property heading the priority's name, e.g. `CRITICAL`. + * @property items the rules, one sentence each. + */ + data class RuleGroup(val heading: PromptText, val items: List) + + /** + * @property heading what introduces the steps. + * @property steps the steps, numbered in order when rendered. + * @property closing when to skip them. + */ + data class Workflow(val heading: PromptText, val steps: List, val closing: PromptText) + + /** @property heading what introduces the tool list. */ + data class Tools(val heading: PromptText) + + /** + * @property noNarration sent under either format: acting means calling, not describing. + * @property native how to call under the function-calling API. + * @property text how to call when calls travel in the reply. + */ + data class ToolCallFormat(val noNarration: PromptText, val native: PromptText, val text: TextFormat) + + /** + * @property instruction the sentence introducing the envelope. + * @property noNativeChannel that a call through the provider's function-calling API is not read. + * @property onlyTheLineRuns that only the envelope line itself runs a tool. + * @property examplesHeading what introduces [examples]. + * @property examples well-formed calls, each with what it is for. + */ + data class TextFormat( + val instruction: PromptText, + val noNativeChannel: PromptText, + val onlyTheLineRuns: PromptText, + val examplesHeading: PromptText, + val examples: List, + ) + + /** + * @property purpose what the call does. + * @property call the call, as the model should write it. + */ + data class Example(val purpose: PromptText, val call: PromptText) + + /** @property systemPrompt the whole prompt; ai-core appends its IDE CONTEXT block after it. */ + data class Layout(val systemPrompt: PromptText) + + companion object { + /** The `schema_version` this code reads; bump it when a key is renamed or removed. */ + const val SCHEMA_VERSION = 1 + } +} diff --git a/plugins/AI-Agent-OpenAI/src/main/kotlin/com/itsaky/androidide/plugins/aiagentopenai/prompt/config/OpenAiPromptConfigParser.kt b/plugins/AI-Agent-OpenAI/src/main/kotlin/com/itsaky/androidide/plugins/aiagentopenai/prompt/config/OpenAiPromptConfigParser.kt new file mode 100644 index 00000000..86093da2 --- /dev/null +++ b/plugins/AI-Agent-OpenAI/src/main/kotlin/com/itsaky/androidide/plugins/aiagentopenai/prompt/config/OpenAiPromptConfigParser.kt @@ -0,0 +1,65 @@ +package com.itsaky.androidide.plugins.aiagentopenai.prompt.config + +import com.itsaky.androidide.plugins.ai.prompt.PromptConfigDocument +import com.itsaky.androidide.plugins.ai.prompt.PromptConfigException +import com.itsaky.androidide.plugins.ai.prompt.PromptConfigLoader +import com.itsaky.androidide.plugins.ai.prompt.PromptConfigObject +import com.itsaky.androidide.plugins.ai.prompt.PromptConfigParser +import com.itsaky.androidide.plugins.aiagentopenai.prompt.config.OpenAiPromptConfig.Example +import com.itsaky.androidide.plugins.aiagentopenai.prompt.config.OpenAiPromptConfig.Layout +import com.itsaky.androidide.plugins.aiagentopenai.prompt.config.OpenAiPromptConfig.RuleGroup +import com.itsaky.androidide.plugins.aiagentopenai.prompt.config.OpenAiPromptConfig.Section +import com.itsaky.androidide.plugins.aiagentopenai.prompt.config.OpenAiPromptConfig.TextFormat +import com.itsaky.androidide.plugins.aiagentopenai.prompt.config.OpenAiPromptConfig.ToolCallFormat +import com.itsaky.androidide.plugins.aiagentopenai.prompt.config.OpenAiPromptConfig.Tools +import com.itsaky.androidide.plugins.aiagentopenai.prompt.config.OpenAiPromptConfig.Workflow + +/** + * Maps the merged config onto a [OpenAiPromptConfig]. Strict: a missing, mistyped or unknown key + * throws [PromptConfigException] naming the file that holds it, so a typo fails on activation. + */ +object OpenAiPromptConfigParser : PromptConfigParser { + + /** + * Parses the config merged from `agent.yml` and its includes; see [PromptConfigLoader]. + * + * @param document the merged top-level keys and the file each came from. + * @return the config. + */ + override fun parse(document: PromptConfigDocument): OpenAiPromptConfig = + document.read { + val version = int("schema_version") + if (version != OpenAiPromptConfig.SCHEMA_VERSION) { + val supported = OpenAiPromptConfig.SCHEMA_VERSION + throw invalid("schema_version", "is $version, but this OpenAI plugin reads $supported") + } + OpenAiPromptConfig( + identity = text("identity"), + scope = obj("scope").read { section() }, + rules = objects("rules").map { it.read { RuleGroup(text("heading"), texts("items")) } }, + behavior = obj("behavior").read { section() }, + workflow = obj("workflow").read { Workflow(text("heading"), texts("steps"), text("closing")) }, + tools = obj("tools").read { Tools(text("heading")) }, + toolCallFormat = obj("tool_call_format").read { + ToolCallFormat( + noNarration = text("no_narration"), + native = text("native"), + text = obj("text").read { + TextFormat( + instruction = text("instruction"), + noNativeChannel = text("no_native_channel"), + onlyTheLineRuns = text("only_the_line_runs"), + examplesHeading = text("examples_heading"), + examples = objects("examples").map { example -> + example.read { Example(text("purpose"), text("call")) } + }, + ) + }, + ) + }, + layout = obj("layout").read { Layout(text("system_prompt")) }, + ) + } + + private fun PromptConfigObject.section() = Section(text("heading"), texts("items")) +} diff --git a/plugins/AI-Agent-OpenAI/src/main/kotlin/com/itsaky/androidide/plugins/aiagentopenai/prompt/config/SharedPromptConfig.kt b/plugins/AI-Agent-OpenAI/src/main/kotlin/com/itsaky/androidide/plugins/aiagentopenai/prompt/config/SharedPromptConfig.kt new file mode 100644 index 00000000..50714095 --- /dev/null +++ b/plugins/AI-Agent-OpenAI/src/main/kotlin/com/itsaky/androidide/plugins/aiagentopenai/prompt/config/SharedPromptConfig.kt @@ -0,0 +1,8 @@ +package com.itsaky.androidide.plugins.aiagentopenai.prompt.config + +import com.itsaky.androidide.plugins.ai.prompt.PromptConfigStore + + + +/** This plugin's prompt config, filled on activation and read by every chat turn. */ +val sharedPromptConfig: PromptConfigStore = PromptConfigStore(OpenAiPromptConfigParser) diff --git a/plugins/AI-Agent-OpenAI/src/main/kotlin/com/itsaky/androidide/plugins/aiagentopenai/settings/BaseUrlPolicy.kt b/plugins/AI-Agent-OpenAI/src/main/kotlin/com/itsaky/androidide/plugins/aiagentopenai/settings/BaseUrlPolicy.kt index 51fb2953..1ccbaae2 100644 --- a/plugins/AI-Agent-OpenAI/src/main/kotlin/com/itsaky/androidide/plugins/aiagentopenai/settings/BaseUrlPolicy.kt +++ b/plugins/AI-Agent-OpenAI/src/main/kotlin/com/itsaky/androidide/plugins/aiagentopenai/settings/BaseUrlPolicy.kt @@ -154,6 +154,17 @@ internal object BaseUrlPolicy { fun requiresApiKey(url: String?): Boolean = keyRequirement(url) == KeyRequirement.REQUIRED + /** + * Whether [url] is OpenAI's own API, the one server here with hosted web search. + * + * @param url the configured base URL + * @return true only for `api.openai.com`; false for any compatible server or an unusable URL + */ + fun isOpenAiApi(url: String?): Boolean { + val accepted = normalize(url) as? BaseUrlResult.Accepted ?: return false + return hostOf(accepted.url.substringAfter("://")).equals(OPENAI_HOST, ignoreCase = true) + } + /** * How the settings pane should present the key field for [url]. * diff --git a/plugins/AI-Agent-OpenAI/src/main/kotlin/com/itsaky/androidide/plugins/aiagentopenai/settings/OpenAiSettingsFragment.kt b/plugins/AI-Agent-OpenAI/src/main/kotlin/com/itsaky/androidide/plugins/aiagentopenai/settings/OpenAiSettingsFragment.kt index 1eba75be..ef92fe0f 100644 --- a/plugins/AI-Agent-OpenAI/src/main/kotlin/com/itsaky/androidide/plugins/aiagentopenai/settings/OpenAiSettingsFragment.kt +++ b/plugins/AI-Agent-OpenAI/src/main/kotlin/com/itsaky/androidide/plugins/aiagentopenai/settings/OpenAiSettingsFragment.kt @@ -31,10 +31,14 @@ import androidx.lifecycle.lifecycleScope import com.google.android.material.dialog.MaterialAlertDialogBuilder import com.google.android.material.textfield.TextInputLayout import com.itsaky.androidide.plugins.PluginContext +import com.itsaky.androidide.plugins.ai.ui.ButtonColors +import com.itsaky.androidide.plugins.ai.ui.FieldColors +import com.itsaky.androidide.plugins.ai.ui.PaneStyle +import com.itsaky.androidide.plugins.ai.ui.RevealToggle +import com.itsaky.androidide.plugins.ai.ui.SecretRevealController +import com.itsaky.androidide.plugins.ai.ui.applyPaneStyling import com.itsaky.androidide.plugins.aiagentopenai.R import com.itsaky.androidide.plugins.aiagentopenai.plugin.OpenAiPlugin -import com.itsaky.androidide.plugins.aiagentopenai.ui.SecretRevealController -import com.itsaky.androidide.plugins.aiagentopenai.ui.applyPaneStyling import com.itsaky.androidide.plugins.base.PluginFragmentHelper import com.itsaky.androidide.plugins.security.KeystoreSecretStore import com.itsaky.androidide.plugins.services.IdeTooltipService @@ -51,6 +55,30 @@ private val OUTLINED_BUTTON_IDS = setOf( R.id.btn_test_connection, ) +/** This plugin's resources for [applyPaneStyling]. */ +private val PANE_STYLE = PaneStyle( + filledButton = ButtonColors( + content = R.color.plugin_button_filled_content, + ripple = R.color.plugin_button_filled_ripple, + container = R.color.plugin_button_filled_container, + ), + outlinedButton = ButtonColors( + content = R.color.plugin_button_outlined_content, + ripple = R.color.plugin_button_outlined_ripple, + stroke = R.color.plugin_button_outlined_stroke, + ), + field = FieldColors( + stroke = R.color.plugin_box_stroke, + error = R.color.plugin_error, + hint = R.color.plugin_text_muted, + endIcon = R.color.plugin_on_surface_variant, + ), + divider = R.color.plugin_outline_variant, + buttonStrokeWidth = R.dimen.button_stroke_width, + cornerRadius = R.dimen.radius_md, + dividerThickness = R.dimen.divider_thickness, +) + /** * This backend's settings pane, mounted by whichever screen offers a backend selector. * @@ -121,7 +149,7 @@ class OpenAiSettingsFragment : Fragment() { // The key section publishes onServerChanged, so it is built before the server section that // fires it, and before the first call below that dresses the pane for the saved server. - view.applyPaneStyling(OUTLINED_BUTTON_IDS) + view.applyPaneStyling(PANE_STYLE, OUTLINED_BUTTON_IDS) setupApiKeyUi(view) setupServerUi(view) setupModelPicker(view, chatModelPicker()) @@ -478,7 +506,12 @@ class OpenAiSettingsFragment : Fragment() { // Not endIconMode="password_toggle": the window has to be flagged secure for as long as the // key is legible, and the built-in toggle gives no hook for that. - val reveal = SecretRevealController(apiKeyBox, apiKeyInput) { legible -> + val reveal = SecretRevealController( + apiKeyBox, + apiKeyInput, + reveal = RevealToggle(R.drawable.ic_visibility, R.string.cd_show_credential), + hide = RevealToggle(R.drawable.ic_visibility_off, R.string.cd_hide_credential), + ) { legible -> setSecureWindow(legible) } reveal.attach() diff --git a/plugins/AI-Agent-OpenAI/src/main/kotlin/com/itsaky/androidide/plugins/aiagentopenai/ui/PaneStyling.kt b/plugins/AI-Agent-OpenAI/src/main/kotlin/com/itsaky/androidide/plugins/aiagentopenai/ui/PaneStyling.kt deleted file mode 100644 index d65bac24..00000000 --- a/plugins/AI-Agent-OpenAI/src/main/kotlin/com/itsaky/androidide/plugins/aiagentopenai/ui/PaneStyling.kt +++ /dev/null @@ -1,77 +0,0 @@ -package com.itsaky.androidide.plugins.aiagentopenai.ui - -import android.content.res.ColorStateList -import android.graphics.Color -import android.view.View -import android.view.ViewGroup -import androidx.annotation.ColorRes -import androidx.core.content.ContextCompat -import com.google.android.material.button.MaterialButton -import com.google.android.material.divider.MaterialDivider -import com.google.android.material.textfield.TextInputLayout -import com.itsaky.androidide.plugins.aiagentopenai.R - -/** How much a pane button stands out: one filled action per section, the rest outlined. */ -private enum class ButtonEmphasis { FILLED, OUTLINED } - -/** - * Gives every Material button, text field and divider under [this] its Material 3 colours, outline - * and ripple in code. The styles' `app:` items are dropped inside the host, so XML alone leaves - * these controls on the host theme's values. - * - * @param outlinedButtonIds the buttons that are secondary actions; every other button is filled. - */ -internal fun View.applyPaneStyling(outlinedButtonIds: Set) { - when (this) { - is MaterialButton -> applyEmphasis( - if (id in outlinedButtonIds) ButtonEmphasis.OUTLINED else ButtonEmphasis.FILLED - ) - is TextInputLayout -> applyOutline() - is MaterialDivider -> applyHairline() - } - if (this is ViewGroup) { - for (i in 0 until childCount) getChildAt(i).applyPaneStyling(outlinedButtonIds) - } -} - -/** Container, label, icon, border and ripple for [emphasis], each with its disabled state. */ -private fun MaterialButton.applyEmphasis(emphasis: ButtonEmphasis) { - val filled = emphasis == ButtonEmphasis.FILLED - val content = colors( - if (filled) R.color.plugin_button_filled_content else R.color.plugin_button_outlined_content - ) - backgroundTintList = if (filled) { - colors(R.color.plugin_button_filled_container) - } else { - ColorStateList.valueOf(Color.TRANSPARENT) - } - setTextColor(content) - iconTint = content - rippleColor = colors( - if (filled) R.color.plugin_button_filled_ripple else R.color.plugin_button_outlined_ripple - ) - strokeColor = colors(R.color.plugin_button_outlined_stroke) - strokeWidth = if (filled) 0 else resources.getDimensionPixelSize(R.dimen.button_stroke_width) - cornerRadius = resources.getDimensionPixelSize(R.dimen.radius_md) -} - -/** Outline, corners, hint and end icon of an outlined-box field. */ -private fun TextInputLayout.applyOutline() { - setBoxStrokeColorStateList(colors(R.color.plugin_box_stroke)) - setBoxStrokeErrorColor(colors(R.color.plugin_error)) - val radius = resources.getDimension(R.dimen.radius_md) - setBoxCornerRadii(radius, radius, radius, radius) - val hint = colors(R.color.plugin_text_muted) - defaultHintTextColor = hint - hintTextColor = hint - setEndIconTintList(colors(R.color.plugin_on_surface_variant)) -} - -private fun MaterialDivider.applyHairline() { - setDividerColorResource(R.color.plugin_outline_variant) - setDividerThicknessResource(R.dimen.divider_thickness) -} - -/** Resolved against this view's context, which carries the plugin's resources. */ -private fun View.colors(@ColorRes id: Int): ColorStateList = - requireNotNull(ContextCompat.getColorStateList(context, id)) diff --git a/plugins/AI-Agent-OpenAI/src/main/kotlin/com/itsaky/androidide/plugins/aiagentopenai/ui/SecretRevealController.kt b/plugins/AI-Agent-OpenAI/src/main/kotlin/com/itsaky/androidide/plugins/aiagentopenai/ui/SecretRevealController.kt deleted file mode 100644 index 227e9bf5..00000000 --- a/plugins/AI-Agent-OpenAI/src/main/kotlin/com/itsaky/androidide/plugins/aiagentopenai/ui/SecretRevealController.kt +++ /dev/null @@ -1,106 +0,0 @@ -package com.itsaky.androidide.plugins.aiagentopenai.ui - -import android.text.method.HideReturnsTransformationMethod -import android.text.method.PasswordTransformationMethod -import android.view.Choreographer -import android.widget.EditText -import com.google.android.material.textfield.TextInputLayout -import com.itsaky.androidide.plugins.aiagentopenai.R - -/** - * The reveal control for this plugin's masked credential field. - * - * The control is the field's own [TextInputLayout] end icon rather than a loose `ImageButton`, - * which is what gives it a real touch target wherever the field is shown. Icon, content - * description and toggle behaviour are decided here and nowhere else, so this pane cannot drift - * from the other AI plugins' panes (ADFA-5491). - * - * Deliberately one copy per AI plugin: each addon is an independent Gradle build sharing only the - * repo's `libs/` jars, so there is nowhere cheaper to put this until the host's plugin-api carries - * it — a change to the masking logic is three edits, on purpose. - * - * @param box the field's own layout, whose end icon becomes the control - * @param field the masked field - * @param onLegibleChanged called with true while the secret stands in clear text, so the caller can - * flag its window secure — which window that is depends on the screen, not on this control - */ -internal class SecretRevealController( - private val box: TextInputLayout, - private val field: EditText, - private val onLegibleChanged: (legible: Boolean) -> Unit, -) { - - /** Whether the secret currently stands in clear text. */ - var isRevealed: Boolean = false - private set - - /** - * Take over [box]'s end icon and mask the field. - * - * The drawable is set here rather than in the layout because an end icon declared as - * `app:endIconDrawable` draws blank inside the host. - */ - fun attach() { - box.endIconMode = TextInputLayout.END_ICON_CUSTOM - // Not announced as a toggle: with END_ICON_CUSTOM nothing ever moves the icon's checked - // state, so TalkBack would read "not checked" over a legible secret. The content - // description below carries the state instead. - box.isEndIconCheckable = false - box.setEndIconOnClickListener { toggle() } - apply() - } - - /** - * Re-mask the secret and report it illegible. - * - * Called when the pane leaves the foreground as well as when a new secret is loaded, so - * neither a screenshot nor the recents thumbnail can catch a revealed credential. - */ - fun mask() { - if (!isRevealed) return - isRevealed = false - apply() - } - - private fun toggle() { - isRevealed = !isRevealed - apply() - } - - /** Dress the field and its icon for [isRevealed], then report what is now legible. */ - private fun apply() { - field.transformationMethod = if (isRevealed) { - HideReturnsTransformationMethod.getInstance() - } else { - PasswordTransformationMethod.getInstance() - } - box.setEndIconDrawable( - if (isRevealed) R.drawable.ic_visibility_off else R.drawable.ic_visibility - ) - box.setEndIconContentDescription( - if (isRevealed) R.string.cd_hide_credential else R.string.cd_show_credential - ) - // Swapping the transformation drops the cursor to the start, so typing would continue in - // front of the key rather than after it. - field.setSelection(field.text?.length ?: 0) - // Masking only invalidates: the secret stays on screen until the next frame is drawn. - if (isRevealed) { - onLegibleChanged(true) - } else { - afterNextDraw { if (!isRevealed) onLegibleChanged(false) } - } - } - - /** - * Run [action] once the next frame has been drawn, or right away if the field is already gone: - * a frame callback runs before that frame's traversal, so a message posted from it lands after - * the field has been redrawn. - */ - private fun afterNextDraw(action: () -> Unit) { - if (!field.isAttachedToWindow) { - action() - return - } - Choreographer.getInstance().postFrameCallback { field.post(action) } - } -} diff --git a/plugins/AI-Agent-OpenAI/src/main/res/values/strings.xml b/plugins/AI-Agent-OpenAI/src/main/res/values/strings.xml index 716a84d5..9188864c 100644 --- a/plugins/AI-Agent-OpenAI/src/main/res/values/strings.xml +++ b/plugins/AI-Agent-OpenAI/src/main/res/values/strings.xml @@ -14,6 +14,7 @@ The server does not have a model called \"%1$s\". Tap Refresh under Model to see what it offers, or type another name. + Web search needs OpenAI\'s own API, and %1$s has none. Read a page with fetch_url instead, or point the server URL at https://api.openai.com/v1. You have reached the server\'s rate limit. Wait a moment and try again. Your OpenAI account has no credit left. OpenAI API keys are prepaid and separate from a ChatGPT subscription — add credit, or point the server URL at a model running on your own machine. The server refused your API key. Re-enter it in Agent settings. @@ -26,6 +27,7 @@ The server returned an error (HTTP %1$d). %2$s Nothing answered at %1$s. Check that the server is running and that this device can reach it. Could not reach OpenAI. Check your internet connection and try again. + The model sent no answer within %1$d seconds. Try again, or choose a faster model. 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. 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. 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. diff --git a/plugins/AI-Agent-OpenAI/src/test/kotlin/com/itsaky/androidide/plugins/aiagentopenai/backend/OpenAiBackendTest.kt b/plugins/AI-Agent-OpenAI/src/test/kotlin/com/itsaky/androidide/plugins/aiagentopenai/backend/OpenAiBackendTest.kt index 27fa42aa..e06342d6 100644 --- a/plugins/AI-Agent-OpenAI/src/test/kotlin/com/itsaky/androidide/plugins/aiagentopenai/backend/OpenAiBackendTest.kt +++ b/plugins/AI-Agent-OpenAI/src/test/kotlin/com/itsaky/androidide/plugins/aiagentopenai/backend/OpenAiBackendTest.kt @@ -4,8 +4,10 @@ import com.itsaky.androidide.plugins.services.LlmInferenceService.CancellableBac import com.itsaky.androidide.plugins.services.LlmInferenceService.HistoryCapableBackend import com.itsaky.androidide.plugins.services.LlmInferenceService.LlmBackend import com.itsaky.androidide.plugins.services.LlmInferenceService.ToolCallingBackend +import com.itsaky.androidide.plugins.services.LlmInferenceService.SystemPromptRequest import io.mockk.mockk import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull import org.junit.Assert.assertTrue import org.junit.Test @@ -15,7 +17,13 @@ import org.junit.Test */ class OpenAiBackendTest { - private val backend = OpenAiBackend(mockk(relaxed = true)) + private val backend = OpenAiBackend(mockk(relaxed = true)) { null } + + @Test + fun givenConfigNotYetLoaded_whenAskedForItsPrompt_thenItReturnsNullInsteadOfBlocking() { + // Null is the contract's "no prompt of my own": ai-core then sends its default prompt. + assertNull(backend.getSystemPrompt(SystemPromptRequest(emptyList(), null, "app/Main.kt"))) + } @Test fun givenTheBackend_whenAskedForItsIdentity_thenItRegistersAsOpenAi() { diff --git a/plugins/AI-Agent-OpenAI/src/test/kotlin/com/itsaky/androidide/plugins/aiagentopenai/backend/OpenAiRequestBuilderTest.kt b/plugins/AI-Agent-OpenAI/src/test/kotlin/com/itsaky/androidide/plugins/aiagentopenai/backend/OpenAiRequestBuilderTest.kt index 38cae968..3f77512a 100644 --- a/plugins/AI-Agent-OpenAI/src/test/kotlin/com/itsaky/androidide/plugins/aiagentopenai/backend/OpenAiRequestBuilderTest.kt +++ b/plugins/AI-Agent-OpenAI/src/test/kotlin/com/itsaky/androidide/plugins/aiagentopenai/backend/OpenAiRequestBuilderTest.kt @@ -192,4 +192,47 @@ class OpenAiRequestBuilderTest { assertFalse(body.has("tools")) } + + @Test + fun givenARequiredDeclaredTool_whenBuildingTheBody_thenToolChoiceNamesIt() { + val config = config().apply { extraParams = mapOf("required_tool" to "web_search") } + + val body = OpenAiRequestBuilder.body( + OpenAiRequestBuilder.messages(emptyList(), "review", null), "gpt-4o", stream = true, + config = config, tuning = defaultTuning, tools = SEARCH_TOOLS, + ) + + val choice = body.getJSONObject("tool_choice") + assertEquals("function", choice.getString("type")) + assertEquals("web_search", choice.getJSONObject("function").getString("name")) + } + + @Test + fun givenATuningThatDroppedToolChoice_whenBuildingTheBody_thenTheToolsAreStillDeclared() { + val config = config().apply { extraParams = mapOf("required_tool" to "web_search") } + + val body = OpenAiRequestBuilder.body( + OpenAiRequestBuilder.messages(emptyList(), "review", null), "gpt-4o", stream = true, + config = config, tuning = defaultTuning.without(RequestTuning.TOOL_CHOICE)!!, tools = SEARCH_TOOLS, + ) + + assertFalse(body.has("tool_choice")) + assertTrue(body.has("tools")) + } + + @Test + fun givenARequiredToolThatIsNotDeclared_whenBuildingTheBody_thenNoToolChoiceIsSent() { + val config = config().apply { extraParams = mapOf("required_tool" to "fetch_url") } + + val body = OpenAiRequestBuilder.body( + OpenAiRequestBuilder.messages(emptyList(), "review", null), "gpt-4o", stream = true, + config = config, tuning = defaultTuning, tools = SEARCH_TOOLS, + ) + + assertFalse(body.has("tool_choice")) + } + + private companion object { + val SEARCH_TOOLS = listOf(ToolDefinition("web_search", "Search the web", emptyMap())) + } } diff --git a/plugins/AI-Agent-OpenAI/src/test/kotlin/com/itsaky/androidide/plugins/aiagentopenai/backend/OpenAiWebSearchTest.kt b/plugins/AI-Agent-OpenAI/src/test/kotlin/com/itsaky/androidide/plugins/aiagentopenai/backend/OpenAiWebSearchTest.kt new file mode 100644 index 00000000..d8dd6f72 --- /dev/null +++ b/plugins/AI-Agent-OpenAI/src/test/kotlin/com/itsaky/androidide/plugins/aiagentopenai/backend/OpenAiWebSearchTest.kt @@ -0,0 +1,114 @@ +package com.itsaky.androidide.plugins.aiagentopenai.backend + +import com.itsaky.androidide.plugins.aiagentopenai.errors.OpenAiErrorFormatter +import com.itsaky.androidide.plugins.aiagentopenai.errors.OpenAiFailure +import com.itsaky.androidide.plugins.aiagentopenai.errors.OpenAiReplyException +import com.itsaky.androidide.plugins.services.LlmInferenceService.LlmConfig +import org.json.JSONObject +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertThrows +import org.junit.Assert.assertTrue +import org.junit.Test + +/** Unit tests for [OpenAiWebSearch], the Responses API search behind ai-core's web_search tool. */ +class OpenAiWebSearchTest { + + @Test + fun givenTheWebSearchExtraParam_whenChecking_thenASearchIsRequested() { + val config = LlmConfig("openai").apply { extraParams = mapOf("web_search" to true) } + + assertTrue(OpenAiWebSearch.isRequested(config)) + assertFalse(OpenAiWebSearch.isRequested(LlmConfig("openai"))) + } + + @Test + fun givenAQuery_whenBuildingTheBody_thenItDeclaresWebSearchAndSendsNoSamplingParameters() { + // Reasoning models refuse temperature and can spend a token cap before answering. + val body = OpenAiWebSearch.body("gpt-5", "latest kotlin version", "Report concisely.") + + assertEquals("gpt-5", body.getString("model")) + assertEquals("latest kotlin version", body.getString("input")) + assertEquals("Report concisely.", body.getString("instructions")) + assertEquals("web_search", body.getJSONArray("tools").getJSONObject(0).getString("type")) + assertFalse(body.has("temperature")) + assertFalse(body.has("max_output_tokens")) + } + + @Test + fun givenNoInstructions_whenBuildingTheBody_thenNoneAreSent() { + assertFalse(OpenAiWebSearch.body("gpt-5", "q", null).has("instructions")) + } + + @Test + fun givenAReplyWithCitations_whenReadingTheAnswer_thenTheTextIsFollowedByItsSources() { + val response = JSONObject( + """ + {"output":[ + {"type":"web_search_call","status":"completed"}, + {"type":"message","content":[{"type":"output_text","text":"Kotlin 2.3 is current.", + "annotations":[ + {"type":"url_citation","url":"https://kotlinlang.org/docs/whatsnew23.html","title":"What's new"}, + {"type":"url_citation","url":"https://kotlinlang.org/docs/whatsnew23.html","title":"What's new"} + ]}]} + ]} + """.trimIndent() + ) + + assertEquals( + "Kotlin 2.3 is current.\n\nSources:\n- What's new: https://kotlinlang.org/docs/whatsnew23.html", + OpenAiWebSearch.answer(response), + ) + } + + @Test + fun givenAReplyWithNoMessage_whenReadingTheAnswer_thenItIsEmpty() { + assertEquals("", OpenAiWebSearch.answer(JSONObject("""{"output":[{"type":"reasoning"}]}"""))) + } + + @Test + fun givenAFailedReplyWithAnErrorObject_whenReadingTheAnswer_thenItThrowsRatherThanReturningEmpty() { + val response = JSONObject( + """{"status":"failed","error":{"code":"server_error","message":"Search backend down."},"output":[]}""" + ) + + assertThrows(OpenAiReplyException::class.java) { OpenAiWebSearch.answer(response) } + } + + @Test + fun givenANullErrorField_whenReadingTheAnswer_thenTheAnswerIsReturned() { + // A successful Responses reply carries `"error": null`, which is not a failure. + val response = JSONObject( + """{"error":null,"output":[{"type":"message","content":[{"type":"output_text","text":"Hi."}]}]}""" + ) + + assertEquals("Hi.", OpenAiWebSearch.answer(response)) + } + + @Test + fun givenAnErrorReplyWithAnInvalidKeyCode_whenClassified_thenTheKeyIsReportedAsRefused() { + val error = replyError("""{"error":{"code":"invalid_api_key","message":"Incorrect API key."}}""") + + assertEquals(OpenAiFailure.KeyRefused, classify(error)) + } + + @Test + fun givenAnErrorReplyWithAnInsufficientQuotaCode_whenClassified_thenBillingIsReported() { + val error = replyError("""{"error":{"code":"insufficient_quota","message":"You exceeded your quota."}}""") + + assertEquals(OpenAiFailure.BillingRequired, classify(error)) + } + + @Test + fun givenAnErrorReplyWithAnUnknownCode_whenClassified_thenTheServersReasonIsKept() { + val error = replyError("""{"error":{"code":"server_error","message":"Search backend down."}}""") + + assertEquals(OpenAiFailure.Failed("Search backend down."), classify(error)) + } + + private fun replyError(body: String): OpenAiReplyException = + assertThrows(OpenAiReplyException::class.java) { OpenAiWebSearch.answer(JSONObject(body)) } + + private fun classify(error: Throwable): OpenAiFailure = + OpenAiErrorFormatter.classify(error, modelName = "gpt-5", hasApiKey = true, isOpenAiHost = true) +} diff --git a/plugins/AI-Agent-OpenAI/src/test/kotlin/com/itsaky/androidide/plugins/aiagentopenai/backend/RequestTuningTest.kt b/plugins/AI-Agent-OpenAI/src/test/kotlin/com/itsaky/androidide/plugins/aiagentopenai/backend/RequestTuningTest.kt index bafd9351..c412c04b 100644 --- a/plugins/AI-Agent-OpenAI/src/test/kotlin/com/itsaky/androidide/plugins/aiagentopenai/backend/RequestTuningTest.kt +++ b/plugins/AI-Agent-OpenAI/src/test/kotlin/com/itsaky/androidide/plugins/aiagentopenai/backend/RequestTuningTest.kt @@ -185,4 +185,15 @@ class UnsupportedParameterTest { assertFalse(UnsupportedTools.rejectedIn(401, """{"error":"unsupported tools"}""")) assertFalse(UnsupportedTools.rejectedIn(400, null)) } + + @Test + fun givenAServerRefusingToolChoice_whenReadingTheError_thenOnlyToolChoiceIsDropped() { + // Read as a tools refusal instead, it would switch the server off native calls for good. + val body = """{"error":{"message":"tool_choice is not supported","param":"tool_choice"}}""" + + assertEquals(RequestTuning.TOOL_CHOICE, UnsupportedParameter.nameIn(body)) + val tuning = RequestTuning(RequestTuning.MAX_TOKENS, sendTemperature = true) + assertEquals(false, tuning.without(RequestTuning.TOOL_CHOICE)?.sendToolChoice) + assertNull(tuning.without(RequestTuning.TOOL_CHOICE)!!.without(RequestTuning.TOOL_CHOICE)) + } } diff --git a/plugins/AI-Agent-OpenAI/src/test/kotlin/com/itsaky/androidide/plugins/aiagentopenai/errors/OpenAiCredentialProblemTest.kt b/plugins/AI-Agent-OpenAI/src/test/kotlin/com/itsaky/androidide/plugins/aiagentopenai/errors/OpenAiCredentialProblemTest.kt index 6fca6aee..c7104386 100644 --- a/plugins/AI-Agent-OpenAI/src/test/kotlin/com/itsaky/androidide/plugins/aiagentopenai/errors/OpenAiCredentialProblemTest.kt +++ b/plugins/AI-Agent-OpenAI/src/test/kotlin/com/itsaky/androidide/plugins/aiagentopenai/errors/OpenAiCredentialProblemTest.kt @@ -39,6 +39,7 @@ class OpenAiCredentialProblemTest { OpenAiFailure.Unexpected(418, null), OpenAiFailure.ServerNotRunning, OpenAiFailure.Unreachable, + OpenAiFailure.TimedOut(180), OpenAiFailure.EmptyReply(skippedChunks = 3), OpenAiFailure.ReasoningOnly, OpenAiFailure.TruncatedBeforeReply, diff --git a/plugins/AI-Agent-OpenAI/src/test/kotlin/com/itsaky/androidide/plugins/aiagentopenai/errors/OpenAiErrorFormatterTest.kt b/plugins/AI-Agent-OpenAI/src/test/kotlin/com/itsaky/androidide/plugins/aiagentopenai/errors/OpenAiErrorFormatterTest.kt index 1e062e23..d8910ea2 100644 --- a/plugins/AI-Agent-OpenAI/src/test/kotlin/com/itsaky/androidide/plugins/aiagentopenai/errors/OpenAiErrorFormatterTest.kt +++ b/plugins/AI-Agent-OpenAI/src/test/kotlin/com/itsaky/androidide/plugins/aiagentopenai/errors/OpenAiErrorFormatterTest.kt @@ -46,6 +46,13 @@ class OpenAiErrorFormatterTest { assertEquals(OpenAiFailure.BillingRequired, classify(body)) } + @Test + fun givenA402_whenClassified_thenItIsBillingRequired() { + // What compatible gateways answer for an empty balance; a status of its own, not a 429. + val body = """OpenAI HTTP 402: {"error":{"message":"Insufficient credits"}}""" + assertEquals(OpenAiFailure.BillingRequired, classify(body)) + } + @Test fun givenA401WithAKeySent_whenClassified_thenTheKeyWasRefused() { val body = """OpenAI HTTP 401: {"error":{"code":"invalid_api_key","message":"bad key"}}""" @@ -98,6 +105,14 @@ class OpenAiErrorFormatterTest { assertEquals(OpenAiFailure.Unreachable, classify("Unable to resolve host", isOpenAiHost = true)) } + @Test + fun givenASilentServerAfterTheRequestWasSent_whenClassified_thenItTimedOutRatherThanUnreachable() { + // A reasoning model thinking past the read timeout; the network is fine. + val error = OpenAiTimeoutException(180_000, java.net.SocketTimeoutException("timeout")) + + assertEquals(OpenAiFailure.TimedOut(180), classify(null, error = error)) + } + @Test fun givenANonIoFailure_whenClassified_thenItIsAGenericFailure() { val failure = OpenAiErrorFormatter.classify( diff --git a/plugins/AI-Agent-OpenAI/src/test/kotlin/com/itsaky/androidide/plugins/aiagentopenai/prompt/OpenAiSystemPromptTest.kt b/plugins/AI-Agent-OpenAI/src/test/kotlin/com/itsaky/androidide/plugins/aiagentopenai/prompt/OpenAiSystemPromptTest.kt index b35f5533..512f34db 100644 --- a/plugins/AI-Agent-OpenAI/src/test/kotlin/com/itsaky/androidide/plugins/aiagentopenai/prompt/OpenAiSystemPromptTest.kt +++ b/plugins/AI-Agent-OpenAI/src/test/kotlin/com/itsaky/androidide/plugins/aiagentopenai/prompt/OpenAiSystemPromptTest.kt @@ -2,6 +2,9 @@ package com.itsaky.androidide.plugins.aiagentopenai.prompt import com.itsaky.androidide.plugins.services.LlmInferenceService.SystemPromptRequest import com.itsaky.androidide.plugins.services.LlmInferenceService.ToolDefinition +import com.itsaky.androidide.plugins.aiagentopenai.prompt.config.DirectoryPromptConfigSource.Companion.shippedConfig +import com.itsaky.androidide.plugins.aiagentopenai.prompt.config.DirectoryPromptConfigSource.Companion.shippedWith +import com.itsaky.androidide.plugins.aiagentopenai.prompt.config.OpenAiPromptConfig import org.junit.Assert.assertEquals import org.junit.Assert.assertFalse import org.junit.Assert.assertTrue @@ -9,10 +12,14 @@ import org.junit.Test /** * The system prompt. The tool-call envelope is what the caller parses back out of the reply, so a - * paraphrase of it would produce replies nothing reads. + * paraphrase of it would produce replies nothing reads. Also: its wording changes by editing + * `assets/prompts/` alone, and a typo is caught, by file. */ class OpenAiSystemPromptTest { + private fun build(request: SystemPromptRequest, config: OpenAiPromptConfig = shippedConfig) = + OpenAiSystemPrompt.build(request, config) + private fun request( syntax: String? = """{"tool":"NAME","args":{}}""", tools: List = listOf( @@ -25,27 +32,32 @@ class OpenAiSystemPromptTest { @Test fun givenAToolCallSyntax_whenBuilt_thenItAppearsVerbatim() { val syntax = """@@CALL{"tool":"NAME"}@@""" - assertTrue(OpenAiSystemPrompt.build(request(syntax = syntax)).contains(syntax)) + assertTrue(build(request(syntax = syntax)).contains(syntax)) } @Test fun givenTools_whenBuilt_thenEachNameAndDescriptionIsListed() { - val prompt = OpenAiSystemPrompt.build(request()) + val prompt = build(request()) assertTrue(prompt.contains("- read_file: Read a file")) assertTrue(prompt.contains("- respond: Finish the task")) } @Test fun givenAnExamplePath_whenBuilt_thenItIsUsedInTheExamples() { - val prompt = OpenAiSystemPrompt.build(request(examplePath = "src/Foo.kt")) + val prompt = build(request(examplePath = "src/Foo.kt")) assertTrue(prompt.contains(""""file_path":"src/Foo.kt"""")) // The stem drives the search_project example. assertTrue(prompt.contains(""""query":"Foo"""")) } + @Test + fun givenADotfileExamplePath_whenBuilt_thenTheStemIsItsWholeName() { + assertTrue(build(request(examplePath = "app/.gitignore")).contains(""""query":".gitignore"""")) + } + @Test fun givenNoTools_whenBuilt_thenThePromptStillBuilds() { - val prompt = OpenAiSystemPrompt.build(request(tools = emptyList())) + val prompt = build(request(tools = emptyList())) assertTrue(prompt.contains("AVAILABLE TOOLS:")) } @@ -53,11 +65,11 @@ class OpenAiSystemPromptTest { fun givenNoToolCallSyntax_whenBuilt_thenTheEnvelopeIsNeverTaught() { // A null syntax means the caller parses no envelope; an example of one is a call that // would not run. - val prompt = OpenAiSystemPrompt.build(request(syntax = null)) + val prompt = build(request(syntax = null)) assertFalse(prompt.contains("")) assertTrue(prompt.contains("AVAILABLE TOOLS:")) - assertTrue(prompt.contains("WORKFLOW:")) + assertTrue(prompt.contains("WORKFLOW — follow these steps only when")) } @Test @@ -65,13 +77,13 @@ class OpenAiSystemPromptTest { // Teaching both (ADFA-5410) is how one call runs twice: the provider carries it and the // text copy is extracted as a second call. listOf(request(), request(syntax = null)).forEach { request -> - assertEquals(1, OpenAiSystemPrompt.build(request).split("TOOL CALL FORMAT").size - 1) + assertEquals(1, build(request).split("TOOL CALL FORMAT").size - 1) } } @Test fun givenNoExamplePath_whenBuilt_thenTheExamplesStillCarryAConcretePath() { - val prompt = OpenAiSystemPrompt.build(request(examplePath = null)) + val prompt = build(request(examplePath = null)) assertTrue(prompt.contains(""""file_path":"app/src/main/java/com/example/MainActivity.kt"""")) } @@ -79,7 +91,7 @@ class OpenAiSystemPromptTest { @Test fun givenAToolCallSyntax_whenBuilt_thenNativeFunctionCallingIsForbidden() { // In envelope mode nothing reads the provider's channel, so a model using it would hang. - val prompt = OpenAiSystemPrompt.build(request()) + val prompt = build(request()) assertTrue(prompt.contains("native function-calling channel")) } @@ -87,7 +99,7 @@ class OpenAiSystemPromptTest { fun givenNoToolCallSyntax_whenBuilt_thenTheFunctionCallingApiIsNamedInstead() { // The reverse of the rule above: the tools are declared, so the channel is the only way in // and forbidding it would leave the model no way to call anything. - val prompt = OpenAiSystemPrompt.build(request(syntax = null)) + val prompt = build(request(syntax = null)) assertTrue(prompt.contains("function-calling")) assertFalse(prompt.contains("Do NOT use your provider's native function-calling channel")) @@ -95,6 +107,106 @@ class OpenAiSystemPromptTest { @Test fun givenAnyRequest_whenBuilt_thenOneToolCallPerReplyIsRequired() { - assertTrue(OpenAiSystemPrompt.build(request()).contains("Emit ONE tool call per reply")) + // Conditional on calling a tool at all: stated absolutely it contradicts the rule below + // that a question is answered in the reply itself, which is most of the traffic now. + val prompt = build(request()) + + assertTrue(prompt.contains("When you call a tool, emit ONE per reply")) + assertFalse(prompt.contains("Emit ONE tool call per reply")) + } + + @Test + fun givenEitherMode_whenBuilt_thenAnOffDomainRequestIsNeverDeclined() { + // A prompt whose stated goal was only building Android apps left a general question no + // legal path through it, and the model declined rather than answer (ADFA-6223). + listOf(request(), request(syntax = null)).forEach { request -> + val prompt = build(request) + + assertTrue(prompt.contains("SCOPE:")) + assertTrue( + prompt.contains("Never decline a request on the grounds that it is not about Android") + ) + } + } + + @Test + fun givenEitherMode_whenBuilt_thenTheBuildWorkflowIsIntroducedConditionally() { + // Every step presumes an app-build task, so stated unconditionally it is the refusal above. + listOf(request(), request(syntax = null)).forEach { request -> + val prompt = build(request) + + assertTrue(prompt.contains("WORKFLOW — follow these steps only when the user tells you to build")) + } + } + + @Test + fun givenSeveralTools_whenBuilt_thenNoLineIsIndented() { + // The Kotlin version interpolated the tool list into a raw string, which defeated + // trimIndent and sent most of the prompt indented by eight spaces. + listOf(request(), request(syntax = null)).forEach { + assertFalse(build(it).lines().any { line -> line.startsWith(" ") }) + } + } + + @Test + fun givenNativeCalling_whenBuilt_thenTheFlagLeavesNoTrace() { + assertFalse(build(request(syntax = null)).contains("{{")) + } + + @Test + fun givenTheShippedFiles_whenChecked_thenThePromptRendersForEveryRequest() { + // A typo fails the render, so the shipped set must have none. + assertEquals(emptyList(), OpenAiSystemPrompt.problems(shippedConfig)) + } + + @Test + fun givenATypo_whenChecked_thenItIsReportedByItsFileAndPathRatherThanDroppingText() { + // The file-per-section design this replaced dropped a file with a typo silently. + val config = shippedWith("rules.yml") { + it.replace("Never fabricate tool output.", "Never fabricate {{TOOL_LIST}} output.") + } + + assertEquals( + listOf("rules.yml: rules[0].items[6]: unknown name {{TOOL_LIST}}"), + OpenAiSystemPrompt.problems(config), + ) + } + + @Test + fun givenANewRule_whenBuilt_thenItIsSentAmongTheRulesWithNoCodeChange() { + val config = shippedWith("rules.yml") { it + " - NEW RULE.\n" } + + val prompt = build(request(), config) + + assertTrue(prompt.indexOf("RULES:") < prompt.indexOf("- NEW RULE.")) + assertTrue(prompt.indexOf("NEW RULE.") < prompt.indexOf("TOOL CALL FORMAT")) + } + + @Test + fun givenANewPriorityGroup_whenBuilt_thenItIsSentAsItsOwnBlockAfterTheRules() { + val config = shippedWith("rules.yml") { it + " - heading: OPTIONAL\n items:\n - Be brief.\n" } + + val prompt = build(request(), config) + + assertTrue(prompt.contains("\n\nOPTIONAL:\n- Be brief.\n\nTOOL CALL FORMAT")) + } + + @Test + fun givenANewIdentity_whenBuilt_thenTheToneChangesWithNoCodeChange() { + val config = shippedWith("agent.yml") { + it.replace(Regex("(?s)identity: >-\n.*?\n\n"), "identity: Eres el asistente de CodeOnTheGo.\n\n") + } + + assertTrue(build(request(), config).startsWith("Eres el asistente de CodeOnTheGo.\n\nSCOPE:")) + } + + @Test + fun givenAReorderedLayout_whenBuilt_thenTheSectionsFollowIt() { + // The order the model reads things in is config too, not code. + val config = shippedWith("layout.yml") { + it.replace(" {{IDENTITY}}\n\n", " {{WORKFLOW_CLOSING}}\n {{IDENTITY}}\n\n") + } + + assertTrue(build(request(), config).startsWith("Skip every one of those steps")) } } diff --git a/plugins/AI-Agent-OpenAI/src/test/kotlin/com/itsaky/androidide/plugins/aiagentopenai/prompt/config/DirectoryPromptConfigSource.kt b/plugins/AI-Agent-OpenAI/src/test/kotlin/com/itsaky/androidide/plugins/aiagentopenai/prompt/config/DirectoryPromptConfigSource.kt new file mode 100644 index 00000000..837b532a --- /dev/null +++ b/plugins/AI-Agent-OpenAI/src/test/kotlin/com/itsaky/androidide/plugins/aiagentopenai/prompt/config/DirectoryPromptConfigSource.kt @@ -0,0 +1,46 @@ +package com.itsaky.androidide.plugins.aiagentopenai.prompt.config + +import com.itsaky.androidide.plugins.ai.prompt.PromptConfigLoader +import com.itsaky.androidide.plugins.ai.prompt.PromptConfigSource +import java.io.File +import java.io.FileNotFoundException +import kotlinx.coroutines.runBlocking + +/** + * Reads config from a directory, so JVM tests render the exact files the `.cgp` ships. + * + * @param root the directory holding the config files. + * @param edits replaces one file's text before it is returned, as a device would see an edited file. + */ +class DirectoryPromptConfigSource( + private val root: File, + private val edits: Map String> = emptyMap(), +) : PromptConfigSource { + + override fun read(path: String): String { + val file = File(root, path) + if (!file.isFile) throw FileNotFoundException(path) + return edits[path]?.invoke(file.readText()) ?: file.readText() + } + + companion object { + /** The shipped config files; unit tests run with the module directory as working dir. */ + val SHIPPED_ROOT = File("src/main/assets/prompts") + + /** The shipped config, loaded once for every test that renders a prompt. */ + val shippedConfig: OpenAiPromptConfig by lazy { load(DirectoryPromptConfigSource(SHIPPED_ROOT)) } + + /** + * Loads the shipped config with one file rewritten by [edit]. + * + * @param file the file to edit, e.g. `rules.yml`. + * @param edit rewrites that file's text. + * @return the config loaded from the edited files. + */ + fun shippedWith(file: String, edit: (String) -> String): OpenAiPromptConfig = + load(DirectoryPromptConfigSource(SHIPPED_ROOT, mapOf(file to edit))) + + private fun load(source: PromptConfigSource): OpenAiPromptConfig = + runBlocking { PromptConfigLoader.load(source, OpenAiPromptConfigParser) } + } +} diff --git a/plugins/AI-Agent-OpenAI/src/test/kotlin/com/itsaky/androidide/plugins/aiagentopenai/prompt/config/OpenAiPromptConfigParserTest.kt b/plugins/AI-Agent-OpenAI/src/test/kotlin/com/itsaky/androidide/plugins/aiagentopenai/prompt/config/OpenAiPromptConfigParserTest.kt new file mode 100644 index 00000000..2d4b9be8 --- /dev/null +++ b/plugins/AI-Agent-OpenAI/src/test/kotlin/com/itsaky/androidide/plugins/aiagentopenai/prompt/config/OpenAiPromptConfigParserTest.kt @@ -0,0 +1,113 @@ +package com.itsaky.androidide.plugins.aiagentopenai.prompt.config + +import com.itsaky.androidide.plugins.ai.prompt.PromptConfigException +import com.itsaky.androidide.plugins.aiagentopenai.prompt.config.DirectoryPromptConfigSource.Companion.shippedConfig +import com.itsaky.androidide.plugins.aiagentopenai.prompt.config.DirectoryPromptConfigSource.Companion.shippedWith +import org.junit.Assert.assertEquals +import org.junit.Assert.assertThrows +import org.junit.Assert.assertTrue +import org.junit.Test + +/** + * Unit tests for [OpenAiPromptConfigParser]: a mistake in a prompt file is refused naming that file + * and the key, rather than reaching the model as a prompt with a hole in it. + */ +class OpenAiPromptConfigParserTest { + + @Test + fun givenTheShippedFiles_whenParsing_thenEveryTextIsLabelledWithItsOwnFileAndPath() { + assertEquals("agent.yml: identity", shippedConfig.identity.label) + assertEquals("scope.yml: scope.items[1]", shippedConfig.scope.items[1].label) + assertEquals("rules.yml: rules[0].items[2]", shippedConfig.rules[0].items[2].label) + assertEquals("workflow.yml: workflow.steps[1]", shippedConfig.workflow.steps[1].label) + assertEquals( + "tools.yml: tool_call_format.text.examples[2].call", + shippedConfig.toolCallFormat.text.examples[2].call.label, + ) + assertEquals("layout.yml: layout.system_prompt", shippedConfig.layout.systemPrompt.label) + } + + @Test + fun givenAFoldedScalar_whenParsing_thenItsLinesAreJoinedIntoOneSentence() { + // Source line wraps must not reach the model as newlines mid-sentence. + val rule = shippedConfig.rules[0].items[0].template + + assertTrue(rule.startsWith("When you call a tool, emit ONE per reply, then stop and wait.")) + assertTrue('\n' !in rule) + } + + @Test + fun givenALiteralBlock_whenParsing_thenItsLineBreaksAreKept() { + val native = shippedConfig.toolCallFormat.native.template + + assertEquals(2, native.lines().size) + } + + @Test + fun givenAMissingNestedKey_whenParsing_thenItIsNamedWithItsFileAndPath() { + assertRefused("tools.yml: tool_call_format.text.no_native_channel is missing", "tools.yml") { + it.replace(Regex("(?m)^ no_native_channel: >-\n( .*\n)+"), "") + } + } + + @Test + fun givenAMissingTopLevelKey_whenParsing_thenItIsReportedAgainstTheEntryFile() { + // No file holds it, so the entry file, which decides what is read, is the one to fix. + assertRefused("agent.yml: scope is missing", "scope.yml") { "other: x\n" } + } + + @Test + fun givenAnExtraNestedKey_whenParsing_thenItIsRefusedAsUnknown() { + assertRefused("tools.yml: tools: unknown key tone; expected heading", "tools.yml") { + it.replace("tools:\n heading: AVAILABLE TOOLS", "tools:\n heading: AVAILABLE TOOLS\n tone: friendly") + } + } + + @Test + fun givenAnExampleWithoutItsCall_whenParsing_thenTheExampleIsNamed() { + assertRefused("tools.yml: tool_call_format.text.examples[0].call is missing", "tools.yml") { + it.replace(Regex("(?m)^ call: '\\{\"tool\":\"respond\".*\n"), "") + } + } + + @Test + fun givenAnUnquotedNumber_whenParsing_thenItIsRefusedAsNotText() { + assertRefused("scope.yml: scope.heading expected text; quote it", "scope.yml") { + it.replace(" heading: SCOPE", " heading: 42") + } + } + + @Test + fun givenAWorkflowWithNoSteps_whenParsing_thenItIsRefused() { + assertRefused("workflow.yml: workflow.steps is empty", "workflow.yml") { + it.replace(Regex("(?s) steps:\n.*?(?= closing:)"), " steps: []\n") + } + } + + @Test + fun givenANewerSchemaVersion_whenParsing_thenItIsRefusedNamingBoth() { + assertRefused("agent.yml: schema_version is 2, but this OpenAI plugin reads 1", "agent.yml") { + it.replace("schema_version: 1", "schema_version: 2") + } + } + + @Test + fun givenBrokenYaml_whenParsing_thenTheFileNameAndPositionAreReported() { + val error = refused("rules.yml") { "rules: [unclosed" } + + assertTrue(error.message!!.startsWith("rules.yml: ")) + assertTrue(error.message!!.contains("line")) + } + + @Test + fun givenADuplicateKeyInOneFile_whenParsing_thenItIsRefused() { + // YAML would otherwise keep the second silently, and an edit to the first would do nothing. + refused("agent.yml") { "$it\nidentity: again\n" } + } + + private fun refused(file: String, edit: (String) -> String): PromptConfigException = + assertThrows(PromptConfigException::class.java) { shippedWith(file, edit) } + + private fun assertRefused(message: String, file: String, edit: (String) -> String) = + assertEquals(message, refused(file, edit).message) +} diff --git a/plugins/AI-Agent-OpenAI/src/test/kotlin/com/itsaky/androidide/plugins/aiagentopenai/prompt/config/ShippedPromptFilesTest.kt b/plugins/AI-Agent-OpenAI/src/test/kotlin/com/itsaky/androidide/plugins/aiagentopenai/prompt/config/ShippedPromptFilesTest.kt new file mode 100644 index 00000000..64f8e8b7 --- /dev/null +++ b/plugins/AI-Agent-OpenAI/src/test/kotlin/com/itsaky/androidide/plugins/aiagentopenai/prompt/config/ShippedPromptFilesTest.kt @@ -0,0 +1,55 @@ +package com.itsaky.androidide.plugins.aiagentopenai.prompt.config + +import com.itsaky.androidide.plugins.ai.prompt.PromptConfigLoader +import com.itsaky.androidide.plugins.ai.prompt.PromptConfigSource +import com.itsaky.androidide.plugins.aiagentopenai.prompt.config.DirectoryPromptConfigSource.Companion.SHIPPED_ROOT +import java.io.File +import kotlinx.coroutines.runBlocking +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +/** The shipped `assets/prompts/` files: every one included once, in order, with no malformed tag. */ +class ShippedPromptFilesTest { + + /** The shipped files, by name. */ + private val shipped: Map = + SHIPPED_ROOT.listFiles { f -> f.extension == "yml" }!!.associate { it.name to it.readText() } + + @Test + fun givenTheShippedEntryFile_whenLoading_thenItAndEveryIncludeAreReadInOrder() { + val paths = mutableListOf() + val source = PromptConfigSource { path -> paths += path; File(SHIPPED_ROOT, path).readText() } + + runBlocking { PromptConfigLoader.load(source, OpenAiPromptConfigParser) } + + assertEquals( + listOf("agent.yml", "scope.yml", "rules.yml", "workflow.yml", "tools.yml", "layout.yml"), + paths, + ) + } + + @Test + fun givenEveryShippedFile_whenListed_thenEachIsIncludedExactlyOnce() { + // A .yml nobody includes is dead wording that looks live to whoever edits it. + val entry = shipped.getValue("agent.yml") + val included = Regex("(?m)^ - (\\S+\\.yml)$").findAll(entry).map { it.groupValues[1] } + + assertEquals(shipped.keys - "agent.yml", included.toSet()) + } + + @Test + fun givenTheShippedFiles_whenScanned_thenNoTagIsMalformed() { + // A `{{name}}` or `{{ #X}}` typo would reach the model verbatim, since it is no tag. + assertTrue(shipped.isNotEmpty()) + for ((name, text) in shipped) { + assertFalse("$name has a malformed tag", MALFORMED_TAG.containsMatchIn(text)) + } + } + + private companion object { + /** A `{{` that opens none of `{{NAME}}`, `{{#NAME}}`, `{{^NAME}}` or `{{/NAME}}`. */ + val MALFORMED_TAG = Regex("""\{\{(?![#^/]?[A-Z])""") + } +} diff --git a/plugins/AI-Agent-OpenAI/src/test/kotlin/com/itsaky/androidide/plugins/aiagentopenai/settings/BaseUrlPolicyTest.kt b/plugins/AI-Agent-OpenAI/src/test/kotlin/com/itsaky/androidide/plugins/aiagentopenai/settings/BaseUrlPolicyTest.kt index 82d4d397..27dad5c6 100644 --- a/plugins/AI-Agent-OpenAI/src/test/kotlin/com/itsaky/androidide/plugins/aiagentopenai/settings/BaseUrlPolicyTest.kt +++ b/plugins/AI-Agent-OpenAI/src/test/kotlin/com/itsaky/androidide/plugins/aiagentopenai/settings/BaseUrlPolicyTest.kt @@ -237,4 +237,17 @@ class BaseUrlPolicyTest { assertFalse(BaseUrlPolicy.sameOrigin(null, "https://api.openai.com/v1")) assertFalse(BaseUrlPolicy.sameOrigin("nonsense", "nonsense")) } + + @Test + fun givenOpenAisOwnApi_whenCheckingForHostedSearch_thenItIsOpenAi() { + assertTrue(BaseUrlPolicy.isOpenAiApi("https://api.openai.com/v1")) + assertTrue(BaseUrlPolicy.isOpenAiApi("https://API.openai.com/v1/")) + } + + @Test + fun givenACompatibleServer_whenCheckingForHostedSearch_thenItIsNotOpenAi() { + assertFalse(BaseUrlPolicy.isOpenAiApi("http://localhost:11434/v1")) + assertFalse(BaseUrlPolicy.isOpenAiApi("https://openrouter.ai/api/v1")) + assertFalse(BaseUrlPolicy.isOpenAiApi(null)) + } } diff --git a/plugins/AI-Core/README.md b/plugins/AI-Core/README.md index 683fdc68..51386215 100644 --- a/plugins/AI-Core/README.md +++ b/plugins/AI-Core/README.md @@ -128,6 +128,131 @@ and a copy of the dialog's own header could otherwise forge structure the user then trusts. Contributing plugins therefore ship no sanitising of their own and depend on the installed `ai-core` for it; the two version independently. +## System prompt config + +The agent's **behaviour** and ai-core's **integration** are kept apart. Everything +the model reads — who the agent is, its rules, their priorities, every heading and +the order it all appears in — is in `src/main/assets/prompts/`, one YAML file per +concern. The Kotlin code only loads them, supplies the run's values and sends the +result. To change the tone, add a rule or translate the prompt, edit those files alone. + +| Layer | Owns | Where | +|---|---|---| +| Config | wording, rules, layout | `assets/prompts/*.yml` | +| Loading | `agent.yml` and its includes, merged into one document | `prompt/config/PromptConfigLoader.kt`, `PromptConfigDocument.kt` | +| Schema | the keys and types, validated strictly | `prompt/config/AgentPromptConfig.kt`, `AgentPromptConfigParser.kt` | +| Cache | read once on activation, held in memory | `prompt/config/PromptConfigStore.kt` | +| Rendering | values into the layout, one pass | `prompt/PromptVariables.kt`, `SystemPromptRenderer.kt`, `ToolResultsPrompt.kt`, `ApprovalPrompt.kt`, `ContextFilesPrompt.kt`, `template/PromptTemplateEngine.kt` | +| Integration | which prompt a run gets, the tool loop, and sending it | `prompt/SystemPromptFactory.kt`, `tool/AgentLoop.kt`, `viewmodel/ChatViewModel.kt` | + +The files are loaded once, when the plugin is activated, and cached in memory, so +no chat turn reads the disk. For each user message the layout is rendered into one +string, the run's system prompt. The model never sees or fetches the files themselves. + +### The files + +`agent.yml` is the entry point. It holds `schema_version`, the agent's `identity` +and an `include` list; the listed files are read in that order and merged with it +into one document: + +| File | Keys | What it is | +|---|---|---| +| `agent.yml` | `schema_version`, `identity`, `include` | The version (`2`; another is refused rather than misread), who the agent is and what it will answer, and the files below. | +| `rules.yml` | `rules` | Priority groups, highest first; each has a `heading` (`CRITICAL`, `IMPORTANT`, `MANDATORY`, `OPTIONAL`) and its `items`. **Adding a rule is adding an item.** | +| `tools.yml` | `tools`, `tool_call_format` | What introduces the tool list, and how to write a call as text (sent only under the text protocol). The list itself is the tools the run offers (`PromptToolCatalog`). | +| `ide_context.yml` | `ide_context`, `session` | One line per fact the IDE can state: open files, module paths. `session` states the device's date and time on every prompt, and that the web tools are there. | +| `agent_loop.yml` | `agent_loop`, `approval` | What the agent is told after each tool batch: the `FAILED:` marker, the truncation notice, what to do next after a success or a failure, the `unfinished` turn sent once when a run that has used tools replies without `respond`, and the `required_tool` turn sent once when a run that had to search first answers without searching. `approval` is what it is told when the user denies a call, asks for a revision, or leaves the dialog unanswered. | +| `context_files.yml` | `context_files` | The heading over the files the user attached to a message. | +| `chat_title.yml` | `chat_title` | The system prompt of the one-off request that names a chat after its first reply. | +| `web_search.yml` | `web_search` | The system prompt of the one-off request the `web_search` tool makes through the active backend. It may use `CURRENT_TIME`, so "latest" is read as of the device's date. | +| `answer_review.yml` | `answer_review` | The system prompt of the second pass over an answer holding code, and what stands in for the evidence when no tool ran. `layout.answer_review` arranges its user turn from `REQUEST`, `EVIDENCE`, `HAS_EVIDENCE` and `DRAFT`; the instruction may use `CURRENT_TIME` and must end on `END_MARKER`, the line the code checks to know the reply was not cut off. | +| `tool_descriptions.yml` | `terminal_tool`, `built_in_tools` | What each of ai-core's own tools is for and what each argument means, keyed by tool name, plus the tool the agent answers with. The model reads it in the tool list and native definitions; the approval dialog shows the same description. | +| `layout.yml` | `layout.system_prompt`, `layout.ide_context`, `layout.tool_results`, `layout.context_files`, `layout.chat_title` | Where each text goes. The IDE CONTEXT layout is also appended to a backend's own prompt; `tool_results` is the user turn after each tool batch; `context_files` frames the attached files appended to the user's message; `chat_title` is the exchange the title request sends. | + +**Connecting a new file** is two edits, and no code: create it, then add it to +`include`. Which file holds a key is up to the files: a top-level key may move to +any included file (or `agent.yml` itself; with no `include`, one file can hold +everything). The rules that keep this safe: + +- **A key belongs to one file.** Defining it in two fails and names both + (`tools.yml: identity is also defined in agent.yml`), so no copy wins silently. +- **Only `agent.yml` includes.** One level, so the whole prompt is always listed in one place. +- **Nothing is loaded by accident.** A `.yml` not listed is not read, and a listed + one that is missing fails the load; `ShippedPromptFilesTest` also fails if a + shipped file is never included. Entries are `.yml` paths under `prompts/`, each listed once. +- **Names cross files; anchors do not.** `layout.yml` places `{{IDENTITY}}` from + `agent.yml` by name. YAML anchors (`&x`/`*x`) work only inside one file. + +Parsing is strict: a missing key, an unknown or misspelled key, a duplicate, an +empty list or an unquoted number fails naming the file that holds it and the path, +for example `rules.yml: rules[1].items is empty`. The parser is the IDE's +`snakeyaml-engine`, which reads plain maps and lists only, with no class binding, so no reflection. + +### Template syntax + +Every text in the config is a template, written in a small in-house Mustache subset: + +- `{{NAME}}` — a value. Config text placed this way is rendered where it lands, with + the values in scope there; run data (a tool's description) is inserted verbatim. +- `{{#NAME}}…{{/NAME}}` — a section: repeated per item of a list (the item's + keys shadow outer ones), rendered once for `true` or non-empty text, dropped for + `false`, null, empty. +- `{{^NAME}}…{{/NAME}}` — an inverted section: rendered only when `{{#NAME}}` would not be. +- Inside a list, `FIRST` and `LAST` say where the item sits, e.g. `{{^FIRST}}` for a separator. + +A line holding only a section tag vanishes, so tags can sit on their own lines. +Names are upper case, so JSON's `}}` in the examples is never read as a tag. + +Every text is named by its YAML path in upper case, whichever file holds it: `identity` is `IDENTITY`, +`tools.heading` is `TOOLS_HEADING`, `ide_context.current_file` is +`IDE_CONTEXT_CURRENT_FILE`, `layout.ide_context` is `LAYOUT_IDE_CONTEXT`. Each +`RULES` item has `HEADING` and `ITEMS`, and each of those has `TEXT`. The run's values: + +| Name | Value | +|---|---| +| `TERMINAL_TOOL` | the tool that answers the user (`respond`) | +| `TOOLS` | list; each has `NAME`, `DESCRIPTION` | +| `TOOL_CALL_SYNTAX` | the tool-call envelope; **null under native tool calling** | +| `EXAMPLE_FILE_PATH` | a real open file, for examples | +| `CURRENT_TIME` | the device's date, time and time zone, e.g. `Friday, 25 September 2026, 14:03 (America/Mexico_City, UTC-06:00)` | +| `HAS_IDE_CONTEXT` | whether anything is open or any module is known | +| `CURRENT_FILE` | the focused file, or null | +| `OTHER_FILES` | the other open tabs, comma separated; empty when none | +| `MODULES` | list; each has `NAME`, `SOURCE_DIR`, `LAYOUT_DIR`, `MANIFEST` (each may be null) | +| `HAS_MODULES` | whether `MODULES` has any | +| `TOOL_RESPONSES` | in `layout.tool_results`: the batch's results, already in `` envelopes | +| `ALL_SUCCEEDED` | in `layout.tool_results`: whether every tool in the batch succeeded | +| `MESSAGE` | in `agent_loop.failed`: what the failed tool reported | +| `KEPT`, `COUNT` | in `agent_loop.truncated`: the part kept, and how many characters were cut | +| `TOOL` | in `approval`: the tool the user did not approve; in `agent_loop.required_tool`: the tool the run had to call first | +| `INSTRUCTION` | in `approval.corrected_with_instruction`: what the user typed, verbatim | +| `MINUTES` | in `approval.timed_out`: how long the dialog waited | +| `USER_TEXT`, `REPLY_TEXT` | in `layout.chat_title`: the chat's first message and the reply to it, each cut to its start, verbatim | +| `FILES` | in `layout.context_files`: list of the attachments that could be read; each has `NAME`, `CONTENT` (verbatim) | + +A built-in tool's name and the shape of its arguments (names, types, which are +required) stay in its handler, since the code runs them; what the tool and each +argument are *for* is `tool_descriptions.yml`'s. Every built-in and every argument +it declares must be described there, and an entry naming no built-in or no real +argument is refused, so a new built-in cannot ship unworded and a typo cannot +describe nothing. A tool contributed by another plugin brings its own description +and is never rewritten. + +The `` envelope and the transcript's `Assistant:` label stay in code: +chat-tuned models are trained on the tag (handed bare prose, a small model re-issues +the call it already ran), `ToolCallExtractor` spots a model imitating it, and the +backend appends its own matching `Assistant:` cue. Tool output is inserted verbatim, +never rendered as a template. + +Rendering is strict: an unknown name throws, naming the text it was in +(`rules.yml: rules[2].items[0]: unknown name {{TERMINAL_TOLL}}`). Activation renders +every layout against runs and tool batches that open and close every section, and +logs any failure, and checks `tool_descriptions.yml` against the built-in tools; +`SystemPromptConfigTest`, `ToolResultsPromptTest`, `ApprovalPromptTest`, +`ContextFilesPromptTest`, `ChatTitleTest` and `ToolDescriptionsTest` fail on one in the shipped files. Two changes need +code: a new key needs `AgentPromptConfig` and its parser, and a new name needs +`PromptVariables`. + ## Key classes Every source file sits in a package named for its layer; nothing is loose at the @@ -149,6 +274,10 @@ root of `com/itsaky/androidide/plugins/aicore/`. - `managers/ChatStorageManager.kt` — chat history persisted as JSON - `logging/` — `LOG_PREFIX` (`AiCore`), prefixing every logcat tag this plugin writes, and `AgentTrace`, the one-stream trace of an agent run +- `prompt/` — system-prompt assembly: `SystemPromptFactory` picks the backend's + prompt or the general one, and `SystemPromptRenderer` renders it from + `PromptVariables`. `prompt/config/` maps `assets/prompts/` onto `AgentPromptConfig`; the + IDE's `ai.prompt` package (in `plugin-api.jar`) loads, validates, caches and renders it. - `fragments/`, `viewmodel/`, `tool/` — the Agent chat, its tool loop and handlers ## License diff --git a/plugins/AI-Core/ai-core.html b/plugins/AI-Core/ai-core.html index ce0834e2..0a99341b 100644 --- a/plugins/AI-Core/ai-core.html +++ b/plugins/AI-Core/ai-core.html @@ -65,6 +65,11 @@

Core functionality

every file-changing action gated behind an approval dialog.
  • Agent settings — one screen to pick the backend and configure it; each backend contributes its own portion of that screen.
  • +
  • Web search — the agent always has web_search (the + backend provider's own search: Google Search grounding for Gemini, the + Responses API for OpenAI) and + fetch_url (reads a page or file, after approval). Every + prompt also carries the device's date and time.
  • Registers a shared inference service so every AI plugin talks to one router instead of bundling its own engine.
  • Dynamic backend registry — backends are added and removed at diff --git a/plugins/AI-Core/build.gradle.kts b/plugins/AI-Core/build.gradle.kts index 1c27c327..d1aad89b 100644 --- a/plugins/AI-Core/build.gradle.kts +++ b/plugins/AI-Core/build.gradle.kts @@ -81,7 +81,10 @@ dependencies { // JSON serialization for session persistence implementation("com.google.code.gson:gson:2.10.1") + testImplementation(files("../../libs/plugin-api.jar")) + // plugin-api's prompt loader parses YAML with the host's copy; JVM tests need their own, same version + testImplementation("org.snakeyaml:snakeyaml-engine:2.10") testImplementation("junit:junit:4.13.2") testImplementation("io.mockk:mockk:1.13.8") testImplementation("org.json:json:20240303") @@ -95,3 +98,8 @@ tasks.matching { it.name.contains("checkDebugAarMetadata") || it.name.contains("checkReleaseAarMetadata") }.configureEach { enabled = false } + +// The prompt tests read src/main/assets/prompts from disk; declared, so a YAML-only edit reruns them. +tasks.withType().configureEach { + inputs.dir("src/main/assets/prompts").withPropertyName("shippedPrompts") +} diff --git a/plugins/AI-Core/src/main/AndroidManifest.xml b/plugins/AI-Core/src/main/AndroidManifest.xml index 34e9e190..0759e86e 100644 --- a/plugins/AI-Core/src/main/AndroidManifest.xml +++ b/plugins/AI-Core/src/main/AndroidManifest.xml @@ -1,6 +1,9 @@ + + + @@ -50,10 +53,11 @@ android:value="1" /> + structure. network.access is for fetch_url alone, which asks before each fetch; + inference and search still go through the backend plugins. --> + android:value="filesystem.read,filesystem.write,system.commands,project.structure,network.access" /> What the agent can do device. +

    Web search

    +

    The agent always knows your device's date and time, and it can look + things up online with two tools:

    +
      +
    • web_search searches through your backend's own provider: Google + Search for Gemini, and web search for OpenAI when the server + URL is OpenAI's own API. It uses your API key, so your provider may bill + searches. On-device models and other OpenAI-compatible servers (Ollama, + LM Studio, OpenRouter) cannot search.
    • +
    • fetch_url reads a web page, a GitHub repository or a raw file + you link. It works with every backend and asks you before each fetch, + showing the address. Long pages are cut to the first few thousand + characters the agent can take in.
    • +
    +

    Pasting code or asking whether something is deprecated or the latest + version makes the agent search before it answers, so a review reflects the + libraries as they are today rather than as the model remembers them. Each + of those messages costs one search.

    +

    When an answer contains code, the agent checks it once more before it is + done: that the code compiles, that each branch does what it should, and that + every version it names came from a search. While that runs the chat shows + Checking the answer…, and the answer may then change in place. This + costs one more request, and is skipped when the agent changed your project + or runs on the device.

    +

    Tools from other plugins

    Other plugins can give the agent tools of their own. AI Agent MCP is one: it connects to Model Context Protocol servers — issue trackers, diff --git a/plugins/AI-Core/src/main/assets/prompts/agent.yml b/plugins/AI-Core/src/main/assets/prompts/agent.yml new file mode 100644 index 00000000..222ffa28 --- /dev/null +++ b/plugins/AI-Core/src/main/assets/prompts/agent.yml @@ -0,0 +1,27 @@ +# The agent's system prompt, for a backend that ships none of its own. Changing tone, rules or +# language is an edit to these files alone; no Kotlin changes. +# +# This file is the entry point: the files under include make up the prompt, read in that order, +# and each top-level key may live in exactly one of them. Every text is a template over the run's +# values, e.g. {{TERMINAL_TOOL}}; see README.md. ai-core validates them on activation, and +# SystemPromptConfigTest fails on a mistake in the shipped files. + +schema_version: 2 + +# Who the agent is and what it will answer; the first thing the model reads. +identity: >- + You are a coding assistant inside CodeOnTheGo. You answer anything the user asks, not only + Android questions. Never refuse a question because it is not about Android or not about this + project — answer it with "{{TERMINAL_TOOL}}". + +include: + - rules.yml + - tools.yml + - ide_context.yml + - agent_loop.yml + - context_files.yml + - chat_title.yml + - tool_descriptions.yml + - web_search.yml + - answer_review.yml + - layout.yml diff --git a/plugins/AI-Core/src/main/assets/prompts/agent_loop.yml b/plugins/AI-Core/src/main/assets/prompts/agent_loop.yml new file mode 100644 index 00000000..a4818c86 --- /dev/null +++ b/plugins/AI-Core/src/main/assets/prompts/agent_loop.yml @@ -0,0 +1,52 @@ +# What the agent is told after each tool batch, in the turn that carries the results back to it. +# layout.yml's tool_results decides where each text goes. + +agent_loop: + # A failed tool's result; MESSAGE is what the tool reported, verbatim. + failed: "FAILED: {{MESSAGE}}" + # A result cut to the size limit; KEPT is its start, COUNT how many characters were dropped. + truncated: |- + {{KEPT}} + …[truncated {{COUNT}} chars] + # The second sentence is there because a search that said firebase-vertexai was replaced by + # firebase-ai was followed by code importing firebase-vertexai (ADFA-6223). + grounding: >- + Treat the tool result(s) above as fact: report only what they actually say, and never invent, + assume, or contradict them. Where a result names a replacement, a removal or a newer version, + it overrides what you remember — in your prose and in every line of code and every dependency + you write — and an API a result calls deprecated or removed never appears in your code. The + parts of the request no tool covers — explanation, design, code — you still write from your + own knowledge. + after_success: >- + The action succeeded. If that was the whole request, you are DONE — reply with the + "{{TERMINAL_TOOL}}" tool briefly confirming what happened. If parts of the request are still + undone, do the next one now: call its tool, or write that part of the answer yourself. Never + reply only to say what you will do next, and do not call a tool for a step the user did not + ask for. + after_failure: If the task is complete, give the user your final answer. Otherwise, call the next tool. + # Sent once when a run that has used tools replies without the terminal tool: only that tool + # finishes such a run, so the reply is read as unfinished rather than as the final answer. + unfinished: >- + Your reply called no tool, so the run is still open. If every part of the request is now + answered, call the "{{TERMINAL_TOOL}}" tool with a one-line summary — do not repeat what you + already wrote. Otherwise do the next part now. + # Sent once when a run that had to check the web first answered without searching; TOOL is the + # search tool. A backend that forces the call never sends it (see VerificationPolicy). + required_tool: >- + Your reply did not call "{{TOOL}}", and this request needs it: your training data is older than + today's date, and the libraries and APIs involved may have been deprecated, removed or replaced + since. Being sure of an API is not a reason to skip the check. Call "{{TOOL}}" now, one query + per library or API, asking whether it is deprecated and what replaces it; then answer again + from what the results say, correcting anything in your reply they contradict. + +# What the agent is told when the user does not approve a tool call. TOOL is the tool's name. +approval: + denied: "User denied permission to execute {{TOOL}}" + # The user asked for a change without saying what. + corrected: "User rejected this {{TOOL}} call and asked you to revise it." + # INSTRUCTION is what the user typed, verbatim. + corrected_with_instruction: >- + User rejected this {{TOOL}} call and asked you to revise it: "{{INSTRUCTION}}". Apply that + instruction and try again. + # MINUTES is how long the dialog waited. + timed_out: "Approval request timed out (no response within {{MINUTES}} minutes). Please try again." diff --git a/plugins/AI-Core/src/main/assets/prompts/answer_review.yml b/plugins/AI-Core/src/main/assets/prompts/answer_review.yml new file mode 100644 index 00000000..d59de861 --- /dev/null +++ b/plugins/AI-Core/src/main/assets/prompts/answer_review.yml @@ -0,0 +1,28 @@ +# The one-off request that checks an answer holding code before the user relies on it: a single +# pass writing a long answer broke rules its prompt stated (ADFA-6223), so a second pass holds the +# draft against them. The request, the evidence and the draft are the user turn, arranged by +# layout.yml's answer_review. CURRENT_TIME is the device's date; END_MARKER is the line that +# proves the reply was not cut off, which the code checks for. + +answer_review: + instruction: >- + You check an answer a coding assistant drafted, before the user relies on it. Today is + {{CURRENT_TIME}}. The evidence is everything the assistant's tools returned while it worked, + and it is the only source of current facts you have. Correct the draft wherever it falls short + of any of these. The code compiles as written: every import names something that exists and is + used, and every annotation, opt-in and dependency the code needs is present. Every branch and + state of the code is traced to the concrete situations that reach it: state that is read is + updated wherever what it describes changes, and situations that need different behavior never + share a branch. Every operation is valid for every value its inputs can hold; where it is + valid for only some, the code narrows what it accepts or handles the rest. Each API is used as + its documentation intends and is current according to the evidence, and nothing a library + already provides is reimplemented by hand. Nothing in the code hedges: an open question is + resolved, or stated in prose. The prose and the code agree: what the prose recommends, the code + does, or the prose says why not. Every version, deprecation, release and date appears in the + evidence, or is marked as unverified; a fact is credited to a search or a link only when the + evidence holds it. Every change the draft makes to the user's code is stated with its reason, + and the findings come before anything the code does well. Keep everything that is already + right, in the draft's language and structure, and say nothing about this check. Reply with the + complete corrected answer and nothing else, then a line holding only {{END_MARKER}}. + # EVIDENCE is empty when no tool ran; this stands in for it. + no_evidence: None. No tool ran, so nothing in the draft was checked against a current source. diff --git a/plugins/AI-Core/src/main/assets/prompts/chat_title.yml b/plugins/AI-Core/src/main/assets/prompts/chat_title.yml new file mode 100644 index 00000000..a6d46ce2 --- /dev/null +++ b/plugins/AI-Core/src/main/assets/prompts/chat_title.yml @@ -0,0 +1,8 @@ +# The system prompt of the one-off request that names a chat after its first reply. The exchange +# itself is the user turn, arranged by layout.yml's chat_title. + +chat_title: + instruction: >- + You name chat conversations between a developer and a coding assistant. Reply with a short + title of 2 to 6 words that says what the developer wants, in the developer's language. Plain + text only: no quotes, no markdown, no trailing punctuation, no explanation. diff --git a/plugins/AI-Core/src/main/assets/prompts/context_files.yml b/plugins/AI-Core/src/main/assets/prompts/context_files.yml new file mode 100644 index 00000000..d61de532 --- /dev/null +++ b/plugins/AI-Core/src/main/assets/prompts/context_files.yml @@ -0,0 +1,5 @@ +# The files the user attached, appended to the message they were attached to. Belongs to the user +# turn rather than the system prompt; layout.yml's context_files decides how each file is framed. + +context_files: + heading: CONTEXT FILES diff --git a/plugins/AI-Core/src/main/assets/prompts/ide_context.yml b/plugins/AI-Core/src/main/assets/prompts/ide_context.yml new file mode 100644 index 00000000..f821b0c3 --- /dev/null +++ b/plugins/AI-Core/src/main/assets/prompts/ide_context.yml @@ -0,0 +1,24 @@ +# What the IDE has open, stated so the agent uses real paths. + +ide_context: + heading: IDE CONTEXT (real paths — use these verbatim, do not rewrite them) + current_file: "File the user is viewing: {{CURRENT_FILE}}" + other_files: "Other open files: {{OTHER_FILES}}" + module_source_dir: "New classes for module '{{NAME}}' go in: {{SOURCE_DIR}}" + module_layout_dir: "Layouts for module '{{NAME}}': {{LAYOUT_DIR}}" + module_manifest: "Manifest for module '{{NAME}}': {{MANIFEST}}" + modules_known: These directories already exist — do not call list_files to rediscover them. + closing: >- + If the user names a file that appears above, use that exact path and do not guess a different + folder or extension. + +# The run's own facts, stated on every prompt whether or not anything is open: no model knows +# today's date, and one never told it can reach the web claims it cannot. +session: + current_time: "Current date and time on the user's device: {{CURRENT_TIME}}" + web_access: >- + You can reach the internet. For anything current or outside what you know — news, releases, + prices, documentation, a library's latest version — call web_search, and call fetch_url to + read a page, a repository or a file the user links. Your training data is older than today, so + check with web_search before you judge code or name a library's API or version. Never say you + cannot access the internet. diff --git a/plugins/AI-Core/src/main/assets/prompts/layout.yml b/plugins/AI-Core/src/main/assets/prompts/layout.yml new file mode 100644 index 00000000..d331d9f9 --- /dev/null +++ b/plugins/AI-Core/src/main/assets/prompts/layout.yml @@ -0,0 +1,104 @@ +# Where each text from the other files goes, by the name it is rendered under (see README.md). +# A line holding only a section tag (#, ^ or /) vanishes, so tags can sit on their own lines. + +layout: + system_prompt: | + {{IDENTITY}} + + {{#RULES}} + {{^FIRST}} + + {{/FIRST}} + {{HEADING}}: + {{#ITEMS}} + - {{TEXT}} + {{/ITEMS}} + {{/RULES}} + + {{TOOLS_HEADING}}: + {{#TOOLS}} + - {{NAME}}: {{DESCRIPTION}} + {{/TOOLS}} + {{#TOOL_CALL_SYNTAX}} + + {{TOOL_CALL_FORMAT_INSTRUCTION}} + {{TOOL_CALL_SYNTAX}} + + {{TOOL_CALL_FORMAT_EXAMPLE_HEADING}}: + {{TOOL_CALL_FORMAT_EXAMPLE}} + {{/TOOL_CALL_SYNTAX}} + + {{LAYOUT_IDE_CONTEXT}} + # Also appended to a backend's own prompt, so the session lines reach every backend. + ide_context: | + {{SESSION_CURRENT_TIME}} + {{SESSION_WEB_ACCESS}} + {{#HAS_IDE_CONTEXT}} + + {{IDE_CONTEXT_HEADING}}: + {{#CURRENT_FILE}} + - {{IDE_CONTEXT_CURRENT_FILE}} + {{/CURRENT_FILE}} + {{#OTHER_FILES}} + - {{IDE_CONTEXT_OTHER_FILES}} + {{/OTHER_FILES}} + {{#MODULES}} + {{#SOURCE_DIR}} + - {{IDE_CONTEXT_MODULE_SOURCE_DIR}} + {{/SOURCE_DIR}} + {{#LAYOUT_DIR}} + - {{IDE_CONTEXT_MODULE_LAYOUT_DIR}} + {{/LAYOUT_DIR}} + {{#MANIFEST}} + - {{IDE_CONTEXT_MODULE_MANIFEST}} + {{/MANIFEST}} + {{/MODULES}} + {{#HAS_MODULES}} + {{IDE_CONTEXT_MODULES_KNOWN}} + {{/HAS_MODULES}} + {{IDE_CONTEXT_CLOSING}} + {{/HAS_IDE_CONTEXT}} + + # The turn after each tool batch. TOOL_RESPONSES is the results in envelopes, + # which stay in code: chat models are trained on the tag, and ToolCallExtractor spots a model + # imitating it. ALL_SUCCEEDED is whether every tool in the batch succeeded. + tool_results: |- + {{TOOL_RESPONSES}}{{AGENT_LOOP_GROUNDING}} {{#ALL_SUCCEEDED}}{{AGENT_LOOP_AFTER_SUCCESS}}{{/ALL_SUCCEEDED}}{{^ALL_SUCCEEDED}}{{AGENT_LOOP_AFTER_FAILURE}}{{/ALL_SUCCEEDED}} + + # The attached files, appended to the user's message after a blank line. FILES is the files that + # could be read, each with NAME and CONTENT; CONTENT is inserted verbatim. + context_files: |- + {{CONTEXT_FILES_HEADING}}: + + {{#FILES}} + === {{NAME}} === + {{CONTENT}} + + {{/FILES}} + + # The user turn of the chat-title request. USER_TEXT and REPLY_TEXT are the first exchange, each + # cut to its start and inserted verbatim. It ends on the cue so a completion model answers with + # just the title. + chat_title: |- + Conversation: + Developer: {{USER_TEXT}} + Assistant: {{REPLY_TEXT}} + + Title: + + # The user turn of the answer review. REQUEST is what the user asked, EVIDENCE what the run's + # tools returned (each call and its result), DRAFT the answer to check; all inserted verbatim. + answer_review: |- + REQUEST: + {{REQUEST}} + + EVIDENCE: + {{#HAS_EVIDENCE}} + {{EVIDENCE}} + {{/HAS_EVIDENCE}} + {{^HAS_EVIDENCE}} + {{ANSWER_REVIEW_NO_EVIDENCE}} + {{/HAS_EVIDENCE}} + + DRAFT: + {{DRAFT}} diff --git a/plugins/AI-Core/src/main/assets/prompts/rules.yml b/plugins/AI-Core/src/main/assets/prompts/rules.yml new file mode 100644 index 00000000..4abf74f1 --- /dev/null +++ b/plugins/AI-Core/src/main/assets/prompts/rules.yml @@ -0,0 +1,23 @@ +# What the agent must and must not do, highest priority first. Adding a rule is adding an item. +# Each group renders as "HEADING:" with its items as "- " lines. + +rules: + - heading: CRITICAL + items: + - Reply with exactly ONE tool call and nothing else. + - Never invent tool output, and never claim an action you did not perform through a tool. + - heading: IMPORTANT + items: + - After a tool call, stop and wait — the real result arrives next turn. + - heading: MANDATORY + items: + - >- + When web_search is among your tools, check with it every claim that can stop being true + over time — whether an API is current, what replaced it, a latest version — before you + make it, and give the link each fact came from. Never approve, or reuse in your own code, + anything the results call deprecated or removed. + - >- + Before you send code, trace every branch to the situations that reach it, make every + operation valid for every value it can receive, and never hedge inside code: resolve the + question, or say in prose that it is open. + - For a greeting or a question you can answer directly, use "{{TERMINAL_TOOL}}". diff --git a/plugins/AI-Core/src/main/assets/prompts/tool_descriptions.yml b/plugins/AI-Core/src/main/assets/prompts/tool_descriptions.yml new file mode 100644 index 00000000..e0cbf510 --- /dev/null +++ b/plugins/AI-Core/src/main/assets/prompts/tool_descriptions.yml @@ -0,0 +1,89 @@ +# What each tool is for, as the model reads it in the tool list and in native tool definitions; +# the approval dialog shows the same description. The code keeps only each tool's name and the +# shape of its arguments. Every built-in tool must be described here, with every argument it takes. + +# The tool the agent answers the user with, whatever it is named. +terminal_tool: + description: >- + Send the user your reply or final answer. It MUST carry a "message" holding the text itself — + calling {{TERMINAL_TOOL}} with no "message" shows the user nothing. + arguments: + message: The reply to show the user. + +# Keyed by tool name. A tool from another plugin brings its own description and is not listed. +built_in_tools: + read_file: + description: Read the contents of a file + arguments: + file_path: Project-relative path of the file to read. + list_files: + description: List files and directories in a given path + arguments: + directory: Project-relative directory to list. Empty or omitted lists the project root. + search_project: + description: Search for files by name or content in the project + arguments: + query: Text to search for; a file name unless searching contents. + project_dir: Project-relative directory to search under. Defaults to the whole project. + search_in_contents: Search inside files instead of matching their names. + open_file: + description: Open a file in the IDE editor + arguments: + file_path: Project-relative path of the file to open. + read_build_output: + description: Read the current build output and status + create_file: + description: Create a new file with given content + arguments: + file_path: Project-relative path of the file to create. + content: The file's full contents. + update_file: + description: Update an existing file with new content + arguments: + file_path: Project-relative path of the file to overwrite. + content: The file's new full contents. + edit_file: + description: >- + Edit an existing file by replacing an exact snippet: give file_path, old_string (text to + find, copied exactly including indentation) and new_string (its replacement; empty deletes + it). old_string must match exactly once unless replace_all is true. Prefer this over + update_file for changing a file. + arguments: + file_path: Project-relative path of the file to edit. + old_string: The exact text to find, copied byte-for-byte from the file including indentation. + new_string: What to put in its place; empty deletes it. + replace_all: Replace every occurrence. When false the text must match exactly once. + add_dependency: + description: Add a Maven dependency to the project build file + arguments: + dependency: Maven coordinate to add, as group:artifact:version. + build_file: Project-relative build file to add it to. Defaults to the app module's. + run_app: + # Success is weaker than "the app is running": the user still has to accept the system install + # prompt, and without saying so the model reports the launch. + description: >- + Build the app and install it on this device. The user has to confirm a system install + prompt, so success means the install started, not that the app is on screen + gradle_sync: + description: Sync the Gradle project (reload dependencies and rebuild cache) + generate_from_template: + description: Generate files from Pebble templates with variable substitution + arguments: + template_name: Name of the registered template to generate from. + variables: Template variables, as a flat object of name to value. + web_search: + description: >- + Search the web for current or outside information — news, releases, documentation, a + library's latest version, whether an API is deprecated — and get back what the results say + with their sources. + arguments: + query: >- + What to look up, phrased as a search query: one claim per query, naming exactly the + library, API or version it is about. + fetch_url: + # The user approves each fetch, so the description says it reaches the internet. + description: >- + Fetch a web page, repository page or raw file from the internet and return its text. Use it + for a URL the user gives or one a web search found. + arguments: + url: The full http or https URL to fetch. diff --git a/plugins/AI-Core/src/main/assets/prompts/tools.yml b/plugins/AI-Core/src/main/assets/prompts/tools.yml new file mode 100644 index 00000000..28c93040 --- /dev/null +++ b/plugins/AI-Core/src/main/assets/prompts/tools.yml @@ -0,0 +1,10 @@ +# The wording around the tool list; the tools themselves are the ones the run offers. + +tools: + heading: Tools + +# Taught only under the text protocol; a natively calling backend gets none of it. +tool_call_format: + instruction: "TOOL CALL FORMAT — emit a single line in EXACTLY this format and nothing after it:" + example_heading: Example + example: '{"tool":"open_file","args":{"file_path":"{{EXAMPLE_FILE_PATH}}"}}' diff --git a/plugins/AI-Core/src/main/assets/prompts/web_search.yml b/plugins/AI-Core/src/main/assets/prompts/web_search.yml new file mode 100644 index 00000000..aaae1cf4 --- /dev/null +++ b/plugins/AI-Core/src/main/assets/prompts/web_search.yml @@ -0,0 +1,13 @@ +# The system prompt of the one-off request the web_search tool makes: the backend searches with its +# provider's own search (Gemini's Google Search grounding, OpenAI's web_search) and the query is the +# user turn. The backend appends the sources it was given; this only says how to report. +# CURRENT_TIME is the device's date and time, so "latest" is read as of today, not as of training. + +web_search: + instruction: >- + Search the web to answer the query. Today is {{CURRENT_TIME}}; "latest" and "current" mean as + of that date. Report what the results say, concisely, keeping exact names, versions, numbers + and dates. For every library, API, class or function the query names, state whether the + results show it as current, deprecated or removed, what replaces it, and the latest stable + version with its release date where they give one. Do not fill gaps from memory: where the + results do not answer part of the query, say which part. diff --git a/plugins/AI-Core/src/main/kotlin/com/itsaky/androidide/plugins/aicore/backends/AiBackend.kt b/plugins/AI-Core/src/main/kotlin/com/itsaky/androidide/plugins/aicore/backends/AiBackend.kt index 4a755864..ef670f18 100644 --- a/plugins/AI-Core/src/main/kotlin/com/itsaky/androidide/plugins/aicore/backends/AiBackend.kt +++ b/plugins/AI-Core/src/main/kotlin/com/itsaky/androidide/plugins/aicore/backends/AiBackend.kt @@ -24,8 +24,14 @@ object AiBackend { /** Sentinel [LlmInferenceService.LlmConfig.backendId] meaning "route to the user-selected backend". */ const val AUTO = "auto" + /** Id AI Agent Local registers its on-device backend under. */ + const val LOCAL_ID = "local" + /** Backend preferred when nothing is stored, matching the pre-split default. */ - const val DEFAULT_ID = "local" + const val DEFAULT_ID = LOCAL_ID + + /** Id AI Agent Gemini registers its backend under. */ + const val GEMINI_ID = "gemini" /** * Preference values written before the value *was* the backend id. Additive-only: removing an diff --git a/plugins/AI-Core/src/main/kotlin/com/itsaky/androidide/plugins/aicore/models/ChatMessage.kt b/plugins/AI-Core/src/main/kotlin/com/itsaky/androidide/plugins/aicore/models/ChatMessage.kt index aa7a30ff..e476b458 100644 --- a/plugins/AI-Core/src/main/kotlin/com/itsaky/androidide/plugins/aicore/models/ChatMessage.kt +++ b/plugins/AI-Core/src/main/kotlin/com/itsaky/androidide/plugins/aicore/models/ChatMessage.kt @@ -46,6 +46,11 @@ data class ChatMessage( * existed, and on every turn the two agree on. */ val historyText: String? = null, + /** + * What each tool call of the run asked and got back, on the run's activity row only; see + * [com.itsaky.androidide.plugins.aicore.viewmodel.AgentActivity.logEntry]. Never sent to the model. + */ + val toolLog: String? = null, /** * Whether this row only reports that the backend is not configured yet. The chat drops those * once the backend answers as ready, so a key saved afterwards leaves no stranded warning diff --git a/plugins/AI-Core/src/main/kotlin/com/itsaky/androidide/plugins/aicore/models/ChatTranscript.kt b/plugins/AI-Core/src/main/kotlin/com/itsaky/androidide/plugins/aicore/models/ChatTranscript.kt index adad74da..1fb4f315 100644 --- a/plugins/AI-Core/src/main/kotlin/com/itsaky/androidide/plugins/aicore/models/ChatTranscript.kt +++ b/plugins/AI-Core/src/main/kotlin/com/itsaky/androidide/plugins/aicore/models/ChatTranscript.kt @@ -28,7 +28,8 @@ import java.util.UUID * I'll read the build file first. * ``` * - * `--- MODEL WROTE` opens [ChatMessage.historyText], present only when it differs from the bubble. + * `--- MODEL WROTE` opens [ChatMessage.historyText], present only when it differs from the bubble, + * and `--- CALLS MADE` opens [ChatMessage.toolLog], present only on a run's activity row. * A message line that would read as a header or that marker is escaped with one leading backslash, * and so is one already starting with backslashes before one — so every line reads back as written. */ @@ -47,6 +48,8 @@ object ChatTranscript { private const val FORMAT_VERSION = "1" private const val HEADER_PREFIX = "--- " private const val HISTORY_MARKER = "--- MODEL WROTE" + // Not "--- TOOL …": that is a TOOL message's header. + private const val TOOL_LOG_MARKER = "--- CALLS MADE" private const val KEY_FORMAT = "format" private const val KEY_NAME = "name" private const val TOKEN_DURATION = "duration=" @@ -83,6 +86,7 @@ object ChatTranscript { append('\n').append(header(message)).append('\n') appendBody(message.text) message.historyText?.let { append(HISTORY_MARKER).append('\n').appendBody(it) } + message.toolLog?.let { append(TOOL_LOG_MARKER).append('\n').appendBody(it) } } } @@ -164,10 +168,14 @@ object ChatTranscript { } // Only the separator export writes (or the empty string after the file's last newline). if (body.lastOrNull() == "") body.removeAt(body.lastIndex) - val marker = body.indexOfFirst(::isHistoryMarker) - val text = if (marker < 0) body else body.subList(0, marker) - val history = if (marker < 0) null else body.subList(marker + 1, body.size) - messages += message(header, text.joinBody(), history?.joinBody()) + val historyAt = body.indexOfFirst(::isHistoryMarker) + val logAt = body.indexOfFirst(::isToolLogMarker) + messages += message( + header, + text = body.section(-1, historyAt, logAt).joinBody(), + historyText = if (historyAt < 0) null else body.section(historyAt, logAt).joinBody(), + toolLog = if (logAt < 0) null else body.section(logAt, historyAt).joinBody(), + ) } return ChatSession(createdAt = now, messages = messages, projectKey = projectKey, name = name) } @@ -221,12 +229,21 @@ object ChatTranscript { private fun isHistoryMarker(line: String): Boolean = line.trimEnd() == HISTORY_MARKER - private fun isStructural(line: String): Boolean = isHeader(line) || isHistoryMarker(line) + private fun isToolLogMarker(line: String): Boolean = line.trimEnd() == TOOL_LOG_MARKER + + private fun isStructural(line: String): Boolean = + isHeader(line) || isHistoryMarker(line) || isToolLogMarker(line) private fun StringBuilder.appendBody(text: String): StringBuilder = apply { text.split('\n').forEach { append(escape(it)).append('\n') } } + /** The lines after [start] up to the next of [others] past it, or the end; -1 starts at the top. */ + private fun List.section(start: Int, vararg others: Int): List { + val end = others.filter { it > start }.minOrNull() ?: size + return subList(start + 1, end) + } + private fun List.joinBody(): String = joinToString("\n", transform = ::unescape) private fun header(message: ChatMessage): String = buildString { @@ -240,7 +257,7 @@ object ChatTranscript { } } - private fun message(header: MatchResult, text: String, historyText: String?): ChatMessage { + private fun message(header: MatchResult, text: String, historyText: String?, toolLog: String?): ChatMessage { val (sender, stamp, tokens) = header.destructured val timestamp = try { Instant.parse(stamp).toEpochMilli() @@ -267,6 +284,7 @@ object ChatTranscript { timestamp = timestamp, durationMs = durationMs, historyText = historyText, + toolLog = toolLog, ) } diff --git a/plugins/AI-Core/src/main/kotlin/com/itsaky/androidide/plugins/aicore/plugin/AiCorePlugin.kt b/plugins/AI-Core/src/main/kotlin/com/itsaky/androidide/plugins/aicore/plugin/AiCorePlugin.kt index 2ce39139..0f3ce913 100644 --- a/plugins/AI-Core/src/main/kotlin/com/itsaky/androidide/plugins/aicore/plugin/AiCorePlugin.kt +++ b/plugins/AI-Core/src/main/kotlin/com/itsaky/androidide/plugins/aicore/plugin/AiCorePlugin.kt @@ -3,11 +3,16 @@ package com.itsaky.androidide.plugins.aicore.plugin import android.content.res.Resources import com.itsaky.androidide.plugins.IPlugin import com.itsaky.androidide.plugins.PluginContext +import com.itsaky.androidide.plugins.ai.prompt.AssetPromptConfigSource import com.itsaky.androidide.plugins.aicore.R import com.itsaky.androidide.plugins.aicore.fragments.AiSettingsFragment import com.itsaky.androidide.plugins.aicore.fragments.ChatFragment +import com.itsaky.androidide.plugins.aicore.prompt.PromptConfigChecks +import com.itsaky.androidide.plugins.aicore.prompt.config.AgentPromptConfig +import com.itsaky.androidide.plugins.aicore.prompt.config.sharedPromptConfig import com.itsaky.androidide.plugins.aicore.services.LlmInferenceServiceImpl import com.itsaky.androidide.plugins.aicore.services.ToolSourceRegistryImpl +import com.itsaky.androidide.plugins.aicore.tool.handlers.BuiltInToolHandlers import com.itsaky.androidide.plugins.aicore.tool.handlers.PathGuard import com.itsaky.androidide.plugins.aicore.tool.sources.ToolSourceStore import com.itsaky.androidide.plugins.aicore.viewmodel.ChatViewModelStore @@ -24,12 +29,21 @@ import com.itsaky.androidide.plugins.services.LlmInferenceService import com.itsaky.androidide.plugins.services.SharedServices import com.itsaky.androidide.plugins.services.ToolSourceRegistry import java.io.File +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.cancel class AiCorePlugin : IPlugin, UIExtension, DocumentationExtension, SettingsExtension { private lateinit var context: PluginContext private var llmService: LlmInferenceService? = null + /** Runs this plugin's background work while it is active; cancelled on deactivation. */ + private var activeScope: CoroutineScope? = null + companion object { /** Must match `plugin.id` in AndroidManifest.xml — keys the host's plugin Context lookup * used by [com.itsaky.androidide.plugins.base.PluginFragmentHelper.getPluginInflater]. */ @@ -115,6 +129,7 @@ class AiCorePlugin : IPlugin, UIExtension, DocumentationExtension, SettingsExten context.logger.info("AI Core Plugin: registered LlmInferenceService in SharedServices") registerToolSourceRegistry() + preloadPromptConfig() PathGuard.setProjectRootProvider { try { @@ -151,9 +166,42 @@ class AiCorePlugin : IPlugin, UIExtension, DocumentationExtension, SettingsExten } } + /** Reads and validates the prompt config now, so the first chat turn does no disk I/O. */ + @OptIn(ExperimentalCoroutinesApi::class) + private fun preloadPromptConfig() { + val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default) + activeScope = scope + val source = AssetPromptConfigSource(context.androidContext.assets) + val load = sharedPromptConfig.preload(scope, source) + load.invokeOnCompletion { error -> + when (error) { + null -> reportLoadedConfig(load.getCompleted()) + is CancellationException -> Unit + else -> context.logger.error("AI Core Plugin: prompt config failed to load", error) + } + } + } + + /** + * Logs that the config loaded, and any name typo its layouts would hit at render time. + * + * @param config the config just loaded. + */ + private fun reportLoadedConfig(config: AgentPromptConfig) { + context.logger.info("AI Core Plugin: loaded prompt config with ${config.rules.size} rule groups") + val problems = PromptConfigChecks.problems(config, BuiltInToolHandlers.create(context)) + for (problem in problems) { + context.logger.warn("AI Core Plugin: $problem; chat turns will fail") + } + } + override fun deactivate(): Boolean { context.logger.info("AI Core Plugin deactivating...") + sharedPromptConfig.clear() + activeScope?.cancel() + activeScope = null + // Backends belong to their own plugins; dropping the service drops the whole registry, and // each backend plugin unregisters itself on its own deactivation. SharedServices.unregister(LlmInferenceService::class.java) diff --git a/plugins/AI-Core/src/main/kotlin/com/itsaky/androidide/plugins/aicore/prompt/ApprovalPrompt.kt b/plugins/AI-Core/src/main/kotlin/com/itsaky/androidide/plugins/aicore/prompt/ApprovalPrompt.kt new file mode 100644 index 00000000..d4217787 --- /dev/null +++ b/plugins/AI-Core/src/main/kotlin/com/itsaky/androidide/plugins/aicore/prompt/ApprovalPrompt.kt @@ -0,0 +1,70 @@ +package com.itsaky.androidide.plugins.aicore.prompt + +import com.itsaky.androidide.plugins.ai.prompt.PromptTemplateEngine +import com.itsaky.androidide.plugins.aicore.prompt.config.AgentPromptConfig + + +/** + * What the agent is told when the user does not approve a tool call, worded by `agent_loop.yml`'s + * `approval`. The loop feeds it back as the call's failure, so the model revises or moves on. + */ +object ApprovalPrompt { + + /** + * @param config the loaded prompt config. + * @param tool the tool the user refused. + * @return the denial message. + */ + fun denied(config: AgentPromptConfig, tool: String): String = + PromptTemplateEngine.render(config.approval.denied, mapOf(PromptVariables.TOOL to tool)) + + /** + * @param config the loaded prompt config. + * @param tool the tool the user asked to revise. + * @param instruction what the user typed; blank when they typed nothing. + * @return the denial message, relaying [instruction] when there is one. + */ + fun corrected(config: AgentPromptConfig, tool: String, instruction: String): String = + if (instruction.isBlank()) { + PromptTemplateEngine.render(config.approval.corrected, mapOf(PromptVariables.TOOL to tool)) + } else { + val values = mapOf(PromptVariables.TOOL to tool, PromptVariables.INSTRUCTION to instruction.trim()) + PromptTemplateEngine.render(config.approval.correctedWithInstruction, values) + } + + /** + * @param config the loaded prompt config. + * @param tool the tool whose dialog went unanswered. + * @param minutes how long the dialog waited. + * @return the denial message. + */ + fun timedOut(config: AgentPromptConfig, tool: String, minutes: Long): String { + val values = mapOf(PromptVariables.TOOL to tool, PromptVariables.MINUTES to minutes.toString()) + return PromptTemplateEngine.render(config.approval.timedOut, values) + } + + /** + * Renders every message, to catch a name typo. + * + * @param config the loaded prompt config. + * @return one message per distinct failure; empty when every one renders. + */ + fun problems(config: AgentPromptConfig): List { + val renders: List<() -> String> = listOf( + { denied(config, CHECK_TOOL) }, + { corrected(config, CHECK_TOOL, "") }, + { corrected(config, CHECK_TOOL, "keep the name") }, + { timedOut(config, CHECK_TOOL, 5) }, + ) + return renders.mapNotNull { check -> + try { + check() + null + } catch (e: IllegalArgumentException) { + e.message + } + }.distinct() + } + + private const val CHECK_TOOL = "edit_file" +} diff --git a/plugins/AI-Core/src/main/kotlin/com/itsaky/androidide/plugins/aicore/prompt/BackendPrompts.kt b/plugins/AI-Core/src/main/kotlin/com/itsaky/androidide/plugins/aicore/prompt/BackendPrompts.kt new file mode 100644 index 00000000..37b1704b --- /dev/null +++ b/plugins/AI-Core/src/main/kotlin/com/itsaky/androidide/plugins/aicore/prompt/BackendPrompts.kt @@ -0,0 +1,75 @@ +package com.itsaky.androidide.plugins.aicore.prompt + +import com.itsaky.androidide.plugins.services.LlmInferenceService +import com.itsaky.androidide.plugins.services.LlmInferenceService.SystemPromptRequest + +/** + * What system-prompt assembly needs from the active backend, and nothing else. + * + * Narrow on purpose: [SystemPromptFactory] depends on these two questions rather than on the whole + * inference service, which is what lets it be tested with a fake instead of a live backend. + */ +interface BackendPrompts { + + /** + * Whether the backend carries tool calls in its provider's own function-calling API rather than + * in the reply text. + * + * Decides both halves of the protocol at once — the schemas sent with the request and the + * envelope the prompt teaches — so the two can never disagree about which one is live. + * + * @return true when the backend declares [LlmInferenceService.ToolCallingBackend]. + */ + fun callsToolsNatively(): Boolean + + /** + * The backend's own system prompt. + * + * @param request the tool list, envelope syntax and example path to build it from. + * @return the prompt, or null when the backend has none, is unreachable, or throws. + */ + fun systemPrompt(request: SystemPromptRequest): String? +} + +/** + * [BackendPrompts] answered by the live inference service. + * + * Every question is asked of the backend resolved at call time, because the user can switch + * backends between runs. A backend that throws answers null rather than propagating: one bad + * `.cgp` must degrade to the default prompt, not break every message. + * + * @param backendId the backend the current run is for. + * @param getService supplies the inference service, or null when it is unavailable. + * @param logWarn records a backend that could not answer. + */ +class ServiceBackendPrompts( + private val backendId: () -> String, + private val getService: () -> LlmInferenceService?, + private val logWarn: (String, Throwable) -> Unit, +) : BackendPrompts { + + override fun callsToolsNatively(): Boolean = + backend() is LlmInferenceService.ToolCallingBackend + + override fun systemPrompt(request: SystemPromptRequest): String? { + val backend = backend() ?: return null + return try { + backend.getSystemPrompt(request)?.takeIf { it.isNotBlank() } + } catch (e: Throwable) { + logWarn("backend '${backendId()}' supplied no system prompt; using the default", e) + null + } + } + + /** + * Resolves the active backend. + * + * @return the backend, or null when it cannot be reached. + */ + private fun backend(): LlmInferenceService.LlmBackend? = try { + getService()?.getBackend(backendId()) + } catch (e: Throwable) { + logWarn("could not resolve backend '${backendId()}'", e) + null + } +} diff --git a/plugins/AI-Core/src/main/kotlin/com/itsaky/androidide/plugins/aicore/prompt/ContextFilesPrompt.kt b/plugins/AI-Core/src/main/kotlin/com/itsaky/androidide/plugins/aicore/prompt/ContextFilesPrompt.kt new file mode 100644 index 00000000..7ddf28d2 --- /dev/null +++ b/plugins/AI-Core/src/main/kotlin/com/itsaky/androidide/plugins/aicore/prompt/ContextFilesPrompt.kt @@ -0,0 +1,89 @@ +package com.itsaky.androidide.plugins.aicore.prompt + +import com.itsaky.androidide.plugins.ai.prompt.PromptConfigProvider +import com.itsaky.androidide.plugins.ai.prompt.PromptTemplateEngine +import com.itsaky.androidide.plugins.aicore.prompt.config.AgentPromptConfig +import java.io.File + +/** + * Renders the files the user attached as the block appended to their message, worded by + * `context_files.yml` and arranged by `layout.context_files`. + * + * Belongs to the user turn rather than the system prompt, so it is built per message; the files' + * contents are inserted verbatim, never rendered as a template. + * + * @param config supplies the cached prompt config. + * @param logWarn records a file that could not be read. + */ +class ContextFilesPrompt( + private val config: PromptConfigProvider, + private val logWarn: (String, Throwable?) -> Unit, +) { + + /** + * Renders the block. + * + * A file that cannot be read is skipped rather than failing the send: the message is still + * worth answering without it, and the heading is written only once something is under it. + * + * @param files the files the user attached. + * @return the block to append, or empty when nothing was attached or none could be read. + */ + suspend fun render(files: List): String { + val entries = files.mapNotNull(::entryFor) + if (entries.isEmpty()) return "" + + return render(config.config(), entries) + } + + /** + * Reads one file. + * + * @param file the attachment to read. + * @return its name and contents, or null when it could not be read. + */ + private fun entryFor(file: File): Pair? { + if (!file.exists() || !file.isFile) { + // Silence here once let a deleted attachment look like one the model had ignored. + logWarn("context file ${file.name} is gone; sending the message without it", null) + return null + } + return try { + file.name to file.readText() + } catch (e: Exception) { + logWarn("could not read context file ${file.name}", e) + null + } + } + + companion object { + /** Keeps the block off the end of the user's own words. */ + private const val SEPARATOR = "\n\n" + + /** + * Renders the block for files already read. Pure and thread-safe. + * + * @param config the loaded prompt config. + * @param entries each file's name and contents; not empty. + * @return the block to append to the user's message. + */ + fun render(config: AgentPromptConfig, entries: List>): String = + SEPARATOR + PromptTemplateEngine.render( + config.layout.contextFiles, + PromptVariables.contextFiles(config, entries), + ) + + /** + * Renders the block against two files, to catch a name typo. + * + * @param config the loaded prompt config. + * @return the failure's message; empty when it renders. + */ + fun problems(config: AgentPromptConfig): List = try { + render(config, listOf("A.kt" to "a", "B.kt" to "b")) + emptyList() + } catch (e: IllegalArgumentException) { + listOfNotNull(e.message) + } + } +} diff --git a/plugins/AI-Core/src/main/kotlin/com/itsaky/androidide/plugins/aicore/prompt/IdeContext.kt b/plugins/AI-Core/src/main/kotlin/com/itsaky/androidide/plugins/aicore/prompt/IdeContext.kt new file mode 100644 index 00000000..1ae7aec0 --- /dev/null +++ b/plugins/AI-Core/src/main/kotlin/com/itsaky/androidide/plugins/aicore/prompt/IdeContext.kt @@ -0,0 +1,44 @@ +package com.itsaky.androidide.plugins.aicore.prompt + +/** + * What the IDE has open and how the project is laid out, read once per prompt. + * + * A value with no behaviour beyond deriving [exampleFilePath] from itself: [IdeContextReader] + * fills it and `ide_context.yml` words it, so neither the services nor the wording is in here. + * + * @property currentFile the focused file, project-relative, or null when nothing is open. + * @property otherFiles other open tabs, project-relative, already capped by the reader. + * @property modules the project's modules, so the agent spends no turns rediscovering them. + */ +data class IdeContext( + val currentFile: String?, + val otherFiles: List, + val modules: List, +) { + + /** Whether there is nothing here worth telling the model. */ + val isEmpty: Boolean + get() = currentFile == null && otherFiles.isEmpty() && modules.isEmpty() + + /** + * The path the tool-call examples should use: a file the IDE really has open, so the examples + * carry this project's own language and layout instead of teaching an Android/Java one. Falls + * back to [FALLBACK_EXAMPLE_PATH] only when nothing is open. + */ + val exampleFilePath: String + get() = currentFile ?: otherFiles.firstOrNull() ?: FALLBACK_EXAMPLE_PATH + + companion object { + /** Nothing open and no modules found; the prompt then carries no context block. */ + val EMPTY = IdeContext(null, emptyList(), emptyList()) + + /** + * Path used in the tool-call examples when the IDE has nothing open, so there is no real one + * to show. A concrete path is what a small model needs to copy the *shape* from — a + * placeholder like "path/to/File.ext" measurably degrades its calls — so this is the + * dominant CoGo project layout rather than a language-neutral token. Whenever a file *is* + * open, [exampleFilePath] uses that instead and this is never seen. + */ + const val FALLBACK_EXAMPLE_PATH = "app/src/main/java/com/example/MainActivity.kt" + } +} diff --git a/plugins/AI-Core/src/main/kotlin/com/itsaky/androidide/plugins/aicore/prompt/IdeContextReader.kt b/plugins/AI-Core/src/main/kotlin/com/itsaky/androidide/plugins/aicore/prompt/IdeContextReader.kt new file mode 100644 index 00000000..062a787e --- /dev/null +++ b/plugins/AI-Core/src/main/kotlin/com/itsaky/androidide/plugins/aicore/prompt/IdeContextReader.kt @@ -0,0 +1,65 @@ +package com.itsaky.androidide.plugins.aicore.prompt + +import com.itsaky.androidide.plugins.PluginContext +import com.itsaky.androidide.plugins.aicore.logging.AgentTrace +import com.itsaky.androidide.plugins.aicore.tool.handlers.PathGuard +import com.itsaky.androidide.plugins.services.IdeEditorService +import java.io.File +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext + +/** + * Reads what the IDE has open and how the project is laid out into an [IdeContext]. + * + * The only part of prompt assembly that touches a service or the disk, so everything downstream of + * it stays pure. + * + * @param getContext supplies the plugin context, or null before the plugin is initialized. + */ +class IdeContextReader(private val getContext: () -> PluginContext?) : IdeContextSource { + + /** + * Reads the open-file state and the module layout. + * + * @return the context; the modules alone when there is no editor service or the read fails, + * since those are worth stating even when nothing is known to be open. + */ + override suspend fun read(): IdeContext { + val root = File(PathGuard.projectRoot()) + val modules = withContext(Dispatchers.IO) { ProjectLayout.describe(root) } + // Paths, not a count: an empty or wrong one here is what sends the agent walking the tree, + // and these are project-relative directory names rather than the user's content. + AgentTrace.stage( + "LAYOUT", + "modules=${modules.size}" + modules.joinToString("") { + " ${it.name}[src=${it.sourceDir} layout=${it.layoutDir} manifest=${it.manifest}]" + }, + ) + + val editor = getContext()?.services?.get(IdeEditorService::class.java) + ?: return IdeContext(null, emptyList(), modules) + + // Editor state is read on the main thread, like every other editor-service call. + val (current, open) = withContext(Dispatchers.Main) { + runCatching { editor.getCurrentFile() to editor.getOpenFiles() } + .getOrDefault(null to emptyList()) + } + + fun relative(file: File): String = runCatching { file.relativeToOrSelf(root).path } + .getOrDefault(file.name) + + return IdeContext( + currentFile = current?.let(::relative), + otherFiles = open.orEmpty() + .filter { it != current } + .take(MAX_OPEN_FILES) + .map(::relative), + modules = modules, + ) + } + + companion object { + /** Max open files named in the prompt's IDE-context block. */ + private const val MAX_OPEN_FILES = 8 + } +} diff --git a/plugins/AI-Core/src/main/kotlin/com/itsaky/androidide/plugins/aicore/prompt/IdeContextSource.kt b/plugins/AI-Core/src/main/kotlin/com/itsaky/androidide/plugins/aicore/prompt/IdeContextSource.kt new file mode 100644 index 00000000..bdcc2cf6 --- /dev/null +++ b/plugins/AI-Core/src/main/kotlin/com/itsaky/androidide/plugins/aicore/prompt/IdeContextSource.kt @@ -0,0 +1,17 @@ +package com.itsaky.androidide.plugins.aicore.prompt + +/** + * Supplies the [IdeContext] a prompt is built around. + * + * An interface so [SystemPromptFactory] depends on the answer rather than on the editor service + * that produces it, which is what lets the assembly be tested without a device. + */ +fun interface IdeContextSource { + + /** + * Reads the context. + * + * @return what the IDE has open, with whatever parts of it could be determined. + */ + suspend fun read(): IdeContext +} diff --git a/plugins/AI-Core/src/main/kotlin/com/itsaky/androidide/plugins/aicore/viewmodel/ProjectLayout.kt b/plugins/AI-Core/src/main/kotlin/com/itsaky/androidide/plugins/aicore/prompt/ProjectLayout.kt similarity index 98% rename from plugins/AI-Core/src/main/kotlin/com/itsaky/androidide/plugins/aicore/viewmodel/ProjectLayout.kt rename to plugins/AI-Core/src/main/kotlin/com/itsaky/androidide/plugins/aicore/prompt/ProjectLayout.kt index 0904b9be..dba07d0e 100644 --- a/plugins/AI-Core/src/main/kotlin/com/itsaky/androidide/plugins/aicore/viewmodel/ProjectLayout.kt +++ b/plugins/AI-Core/src/main/kotlin/com/itsaky/androidide/plugins/aicore/prompt/ProjectLayout.kt @@ -1,4 +1,4 @@ -package com.itsaky.androidide.plugins.aicore.viewmodel +package com.itsaky.androidide.plugins.aicore.prompt import java.io.File diff --git a/plugins/AI-Core/src/main/kotlin/com/itsaky/androidide/plugins/aicore/prompt/PromptConfigChecks.kt b/plugins/AI-Core/src/main/kotlin/com/itsaky/androidide/plugins/aicore/prompt/PromptConfigChecks.kt new file mode 100644 index 00000000..b1a9e445 --- /dev/null +++ b/plugins/AI-Core/src/main/kotlin/com/itsaky/androidide/plugins/aicore/prompt/PromptConfigChecks.kt @@ -0,0 +1,27 @@ +package com.itsaky.androidide.plugins.aicore.prompt + +import com.itsaky.androidide.plugins.aicore.prompt.config.AgentPromptConfig +import com.itsaky.androidide.plugins.aicore.tool.ToolHandler +import com.itsaky.androidide.plugins.aicore.tool.web.BackendWebSearch +import com.itsaky.androidide.plugins.aicore.viewmodel.AnswerReview +import com.itsaky.androidide.plugins.aicore.viewmodel.ChatTitle + +/** + * Every render-time check activation runs on a freshly loaded config, in one list so a test covers + * exactly what activation does; a renderer left out here would only fail on its first chat turn. + */ +object PromptConfigChecks { + + /** + * Checks [config] against every prompt that renders from it. + * + * @param config the loaded prompt config. + * @param builtIns ai-core's own handlers, whose descriptions the config must supply. + * @return one message per problem, each naming its file and path; empty when all will render. + */ + fun problems(config: AgentPromptConfig, builtIns: List): List = + SystemPromptRenderer.problems(config) + ToolResultsPrompt.problems(config) + + ApprovalPrompt.problems(config) + ContextFilesPrompt.problems(config) + ChatTitle.problems(config) + + ToolDescriptions.problems(config, builtIns) + BackendWebSearch.problems(config) + + AnswerReview.problems(config) +} diff --git a/plugins/AI-Core/src/main/kotlin/com/itsaky/androidide/plugins/aicore/prompt/PromptToolCatalog.kt b/plugins/AI-Core/src/main/kotlin/com/itsaky/androidide/plugins/aicore/prompt/PromptToolCatalog.kt new file mode 100644 index 00000000..72c4e0d1 --- /dev/null +++ b/plugins/AI-Core/src/main/kotlin/com/itsaky/androidide/plugins/aicore/prompt/PromptToolCatalog.kt @@ -0,0 +1,48 @@ +package com.itsaky.androidide.plugins.aicore.prompt + +import com.itsaky.androidide.plugins.aicore.prompt.config.AgentPromptConfig +import com.itsaky.androidide.plugins.aicore.tool.AgentTools +import com.itsaky.androidide.plugins.aicore.tool.ToolSchema +import com.itsaky.androidide.plugins.services.LlmInferenceService + +/** + * The tool list the model is shown, assembled once and used for both halves of the protocol. + * + * Kept in one place because the prose a text-protocol model reads and the schemas a natively + * calling backend is sent must never name different tools. + */ +object PromptToolCatalog { + + /** + * The terminal tool's arguments: a schema, not emptyMap(), since under native calling a + * parameterless declaration is one the model cannot put its answer in. + */ + val TERMINAL_TOOL_SCHEMA: Map = ToolSchema.objectOf( + "message" to ToolSchema.string(), + required = listOf("message"), + ) + + /** + * The snapshot's budgeted tools, plus [terminalTool], which is not a handler but is how the + * model addresses the user. Built-in wording comes from `tool_descriptions.yml`. + * + * The cap is applied when the snapshot is built, not here, so the grammar the local backend is + * constrained by and the list the prompt describes can never disagree. + * + * @param tools the snapshot this run is using. + * @param terminalTool the name of the tool that ends a run by answering the user. + * @param config the loaded prompt config. + * @return the definitions to hand the backend. + */ + fun definitions( + tools: AgentTools, + terminalTool: String, + config: AgentPromptConfig, + ): List { + val contributed = tools.contributedHandlers.mapTo(mutableSetOf()) { it.toolName } + val budgeted = tools.promptTools.definitions + val builtInNames = budgeted.map { it.name }.filterTo(mutableSetOf()) { it !in contributed } + return ToolDescriptions.apply(config, terminalTool, budgeted, builtInNames) + + ToolDescriptions.terminalTool(config, terminalTool, TERMINAL_TOOL_SCHEMA) + } +} diff --git a/plugins/AI-Core/src/main/kotlin/com/itsaky/androidide/plugins/aicore/prompt/PromptVariables.kt b/plugins/AI-Core/src/main/kotlin/com/itsaky/androidide/plugins/aicore/prompt/PromptVariables.kt new file mode 100644 index 00000000..9c5243e8 --- /dev/null +++ b/plugins/AI-Core/src/main/kotlin/com/itsaky/androidide/plugins/aicore/prompt/PromptVariables.kt @@ -0,0 +1,243 @@ +package com.itsaky.androidide.plugins.aicore.prompt + +import com.itsaky.androidide.plugins.aicore.prompt.config.AgentPromptConfig +import com.itsaky.androidide.plugins.services.LlmInferenceService.SystemPromptRequest + +/** + * The values the prompt config is rendered with: its texts, named by YAML path, and the run's. + * Every key is always present, empty when it does not apply, so a name missing here is a typo in + * a file and fails the render instead of silently dropping text. + */ +object PromptVariables { + + // Config texts: `identity` is IDENTITY, `tools.heading` is TOOLS_HEADING, and so on. + const val IDENTITY = "IDENTITY" + const val RULES = "RULES" + const val HEADING = "HEADING" + const val ITEMS = "ITEMS" + const val TEXT = "TEXT" + const val TOOLS_HEADING = "TOOLS_HEADING" + const val TOOL_CALL_FORMAT_INSTRUCTION = "TOOL_CALL_FORMAT_INSTRUCTION" + const val TOOL_CALL_FORMAT_EXAMPLE_HEADING = "TOOL_CALL_FORMAT_EXAMPLE_HEADING" + const val TOOL_CALL_FORMAT_EXAMPLE = "TOOL_CALL_FORMAT_EXAMPLE" + const val IDE_CONTEXT_HEADING = "IDE_CONTEXT_HEADING" + const val IDE_CONTEXT_CURRENT_FILE = "IDE_CONTEXT_CURRENT_FILE" + const val IDE_CONTEXT_OTHER_FILES = "IDE_CONTEXT_OTHER_FILES" + const val IDE_CONTEXT_MODULE_SOURCE_DIR = "IDE_CONTEXT_MODULE_SOURCE_DIR" + const val IDE_CONTEXT_MODULE_LAYOUT_DIR = "IDE_CONTEXT_MODULE_LAYOUT_DIR" + const val IDE_CONTEXT_MODULE_MANIFEST = "IDE_CONTEXT_MODULE_MANIFEST" + const val IDE_CONTEXT_MODULES_KNOWN = "IDE_CONTEXT_MODULES_KNOWN" + const val IDE_CONTEXT_CLOSING = "IDE_CONTEXT_CLOSING" + const val SESSION_CURRENT_TIME = "SESSION_CURRENT_TIME" + const val SESSION_WEB_ACCESS = "SESSION_WEB_ACCESS" + const val LAYOUT_IDE_CONTEXT = "LAYOUT_IDE_CONTEXT" + const val AGENT_LOOP_GROUNDING = "AGENT_LOOP_GROUNDING" + const val AGENT_LOOP_AFTER_SUCCESS = "AGENT_LOOP_AFTER_SUCCESS" + const val AGENT_LOOP_AFTER_FAILURE = "AGENT_LOOP_AFTER_FAILURE" + const val CONTEXT_FILES_HEADING = "CONTEXT_FILES_HEADING" + const val ANSWER_REVIEW_NO_EVIDENCE = "ANSWER_REVIEW_NO_EVIDENCE" + + /** What the user asked, verbatim; inside `layout.answer_review`. */ + const val REQUEST = "REQUEST" + + /** What the run's tools returned, verbatim; inside `layout.answer_review`. Empty when none ran. */ + const val EVIDENCE = "EVIDENCE" + + /** Whether any tool ran, so [EVIDENCE] holds anything; inside `layout.answer_review`. */ + const val HAS_EVIDENCE = "HAS_EVIDENCE" + + /** The answer to check, verbatim; inside `layout.answer_review`. */ + const val DRAFT = "DRAFT" + + /** The line a complete review reply ends on; inside `answer_review.instruction`. */ + const val END_MARKER = "END_MARKER" + + /** A tool batch's results, already in their `` envelopes; inside `layout.tool_results`. */ + const val TOOL_RESPONSES = "TOOL_RESPONSES" + + /** Whether every tool in the batch succeeded; inside `layout.tool_results`. */ + const val ALL_SUCCEEDED = "ALL_SUCCEEDED" + + /** What a failed tool reported, verbatim; inside `agent_loop.failed`. */ + const val MESSAGE = "MESSAGE" + + /** The part of a result kept under the size limit; inside `agent_loop.truncated`. */ + const val KEPT = "KEPT" + + /** How many characters were cut from a result; inside `agent_loop.truncated`. */ + const val COUNT = "COUNT" + + /** + * The tool the user did not approve, inside `approval`; or the one a run must call before it + * answers, inside `agent_loop.required_tool`. + */ + const val TOOL = "TOOL" + + /** What the user typed when asking for a revision, verbatim; inside `approval.corrected_with_instruction`. */ + const val INSTRUCTION = "INSTRUCTION" + + /** How long the approval dialog waited; inside `approval.timed_out`. */ + const val MINUTES = "MINUTES" + + /** The attached files that could be read, each with [NAME] and [CONTENT]; inside `layout.context_files`. */ + const val FILES = "FILES" + + /** The chat's first user message, cut to its start; inside `layout.chat_title`. */ + const val USER_TEXT = "USER_TEXT" + + /** The agent's reply to it, cut to its start; inside `layout.chat_title`. */ + const val REPLY_TEXT = "REPLY_TEXT" + + /** An attached file's text, verbatim; inside `{{#FILES}}`. */ + const val CONTENT = "CONTENT" + + /** The tool that ends a run by answering the user. */ + const val TERMINAL_TOOL = "TERMINAL_TOOL" + + /** The tools this run offers, each with [NAME] and [DESCRIPTION]. */ + const val TOOLS = "TOOLS" + + /** The tool-call envelope; null under native calling, where the text protocol is not taught. */ + const val TOOL_CALL_SYNTAX = "TOOL_CALL_SYNTAX" + + /** A real project path to show in examples. */ + const val EXAMPLE_FILE_PATH = "EXAMPLE_FILE_PATH" + + /** The device's date, time and time zone; see [SessionContext]. */ + const val CURRENT_TIME = "CURRENT_TIME" + + /** Whether the IDE has anything open or any module worth stating. */ + const val HAS_IDE_CONTEXT = "HAS_IDE_CONTEXT" + + /** The focused file, or null. */ + const val CURRENT_FILE = "CURRENT_FILE" + + /** The other open tabs, comma separated; empty when there are none. */ + const val OTHER_FILES = "OTHER_FILES" + + /** The project's modules, each with [NAME], [SOURCE_DIR], [LAYOUT_DIR] and [MANIFEST]. */ + const val MODULES = "MODULES" + + /** Whether [MODULES] has any, for text stated once rather than per module. */ + const val HAS_MODULES = "HAS_MODULES" + + /** A tool's, a module's or an attached file's name, inside `{{#TOOLS}}`, `{{#MODULES}}` or `{{#FILES}}`. */ + const val NAME = "NAME" + + /** A tool's description, inside `{{#TOOLS}}`. */ + const val DESCRIPTION = "DESCRIPTION" + + /** A module's source directory, or null; inside `{{#MODULES}}`. */ + const val SOURCE_DIR = "SOURCE_DIR" + + /** A module's layout directory, or null; inside `{{#MODULES}}`. */ + const val LAYOUT_DIR = "LAYOUT_DIR" + + /** A module's manifest, or null; inside `{{#MODULES}}`. */ + const val MANIFEST = "MANIFEST" + + /** + * Collects every value `layout.system_prompt` may use. + * + * @param config the loaded prompt config. + * @param request the tool list, envelope syntax and example path this run needs described. + * @param terminalTool the name of the tool that ends a run by answering the user. + * @param context what the IDE has open. + * @param session the clock. + * @return the values, keyed by name. + */ + fun collect( + config: AgentPromptConfig, + request: SystemPromptRequest, + terminalTool: String, + context: IdeContext, + session: SessionContext, + ): Map = ideContext(config, context, session) + mapOf( + IDENTITY to config.identity, + RULES to config.rules.map { group -> + mapOf(HEADING to group.heading, ITEMS to group.items.map { mapOf(TEXT to it) }) + }, + TOOLS_HEADING to config.tools.heading, + TOOL_CALL_FORMAT_INSTRUCTION to config.toolCallFormat.instruction, + TOOL_CALL_FORMAT_EXAMPLE_HEADING to config.toolCallFormat.exampleHeading, + TOOL_CALL_FORMAT_EXAMPLE to config.toolCallFormat.example, + LAYOUT_IDE_CONTEXT to config.layout.ideContext, + TERMINAL_TOOL to terminalTool, + // Plain Strings, so a contributed tool's description is never rendered as a template. + TOOLS to request.tools.map { mapOf(NAME to it.name, DESCRIPTION to it.description) }, + EXAMPLE_FILE_PATH to (request.exampleFilePath ?: IdeContext.FALLBACK_EXAMPLE_PATH), + // Under native calling teaching an envelope too invites both, and the text one runs twice. + TOOL_CALL_SYNTAX to request.toolCallSyntax?.takeIf { it.isNotBlank() }, + ) + + /** + * Collects the values `layout.tool_results` uses. + * + * @param config the loaded prompt config. + * @param terminalTool the name of the tool that ends a run by answering the user. + * @param toolResponses the batch's results, already enveloped; a plain String, so never rescanned. + * @param allSucceeded whether every tool in the batch succeeded. + * @return the values, keyed by name. + */ + fun toolResults( + config: AgentPromptConfig, + terminalTool: String, + toolResponses: String, + allSucceeded: Boolean, + ): Map = mapOf( + AGENT_LOOP_GROUNDING to config.agentLoop.grounding, + AGENT_LOOP_AFTER_SUCCESS to config.agentLoop.afterSuccess, + AGENT_LOOP_AFTER_FAILURE to config.agentLoop.afterFailure, + TERMINAL_TOOL to terminalTool, + TOOL_RESPONSES to toolResponses, + ALL_SUCCEEDED to allSucceeded, + ) + + /** + * Collects the values `layout.context_files` uses. + * + * @param config the loaded prompt config. + * @param files each readable attachment's name and text; plain Strings, so never rescanned. + * @return the values, keyed by name. + */ + fun contextFiles(config: AgentPromptConfig, files: List>): Map = mapOf( + CONTEXT_FILES_HEADING to config.contextFiles.heading, + FILES to files.map { (name, content) -> mapOf(NAME to name, CONTENT to content) }, + ) + + /** + * Collects the values `layout.ide_context` uses, for appending it to a backend's own prompt. + * + * @param config the loaded prompt config. + * @param context what the IDE has open. + * @param session the clock. + * @return the values, keyed by name. + */ + fun ideContext( + config: AgentPromptConfig, + context: IdeContext, + session: SessionContext, + ): Map { + val text = config.ideContext + return mapOf( + SESSION_CURRENT_TIME to config.session.currentTime, + SESSION_WEB_ACCESS to config.session.webAccess, + CURRENT_TIME to session.currentTime, + IDE_CONTEXT_HEADING to text.heading, + IDE_CONTEXT_CURRENT_FILE to text.currentFile, + IDE_CONTEXT_OTHER_FILES to text.otherFiles, + IDE_CONTEXT_MODULE_SOURCE_DIR to text.moduleSourceDir, + IDE_CONTEXT_MODULE_LAYOUT_DIR to text.moduleLayoutDir, + IDE_CONTEXT_MODULE_MANIFEST to text.moduleManifest, + IDE_CONTEXT_MODULES_KNOWN to text.modulesKnown, + IDE_CONTEXT_CLOSING to text.closing, + HAS_IDE_CONTEXT to !context.isEmpty, + CURRENT_FILE to context.currentFile, + OTHER_FILES to context.otherFiles.joinToString(", "), + MODULES to context.modules.map { + mapOf(NAME to it.name, SOURCE_DIR to it.sourceDir, LAYOUT_DIR to it.layoutDir, MANIFEST to it.manifest) + }, + HAS_MODULES to context.modules.isNotEmpty(), + ) + } +} diff --git a/plugins/AI-Core/src/main/kotlin/com/itsaky/androidide/plugins/aicore/prompt/SessionContext.kt b/plugins/AI-Core/src/main/kotlin/com/itsaky/androidide/plugins/aicore/prompt/SessionContext.kt new file mode 100644 index 00000000..b4b9a672 --- /dev/null +++ b/plugins/AI-Core/src/main/kotlin/com/itsaky/androidide/plugins/aicore/prompt/SessionContext.kt @@ -0,0 +1,35 @@ +package com.itsaky.androidide.plugins.aicore.prompt + +import java.time.ZonedDateTime +import java.time.format.DateTimeFormatter +import java.util.Locale + +/** + * The run's own facts, stated on every prompt whether or not the IDE has anything open: no model + * knows today's date, and one never told whether it can reach the web answers "I have no access to + * real-time information" even to a question it has a tool for (ADFA-6223). + * + * @property currentTime the device's date, time and time zone, already worded for the prompt. + */ +data class SessionContext( + val currentTime: String, +) { + + companion object { + + /** English whatever the device locale, like the rest of the prompt. */ + private val FORMAT = DateTimeFormatter.ofPattern("EEEE, d MMMM yyyy, HH:mm", Locale.US) + + /** + * Reads the clock now. + * + * @param now the moment to state; the device clock in its own zone unless a test pins it. + * @return the session, e.g. "Friday, 25 September 2026, 14:03 (America/Mexico_City, UTC-06:00)". + */ + fun current(now: ZonedDateTime = ZonedDateTime.now()): SessionContext { + // The zone id and the offset both: "what time is it in Tokyo" needs the offset to work from. + val offset = now.offset.id.let { if (it == "Z") "+00:00" else it } + return SessionContext("${now.format(FORMAT)} (${now.zone.id}, UTC$offset)") + } + } +} diff --git a/plugins/AI-Core/src/main/kotlin/com/itsaky/androidide/plugins/aicore/prompt/SystemPromptFactory.kt b/plugins/AI-Core/src/main/kotlin/com/itsaky/androidide/plugins/aicore/prompt/SystemPromptFactory.kt new file mode 100644 index 00000000..022a8ea6 --- /dev/null +++ b/plugins/AI-Core/src/main/kotlin/com/itsaky/androidide/plugins/aicore/prompt/SystemPromptFactory.kt @@ -0,0 +1,57 @@ +package com.itsaky.androidide.plugins.aicore.prompt + +import com.itsaky.androidide.plugins.ai.prompt.PromptConfigProvider +import com.itsaky.androidide.plugins.aicore.prompt.config.AgentPromptConfig +import com.itsaky.androidide.plugins.services.LlmInferenceService.SystemPromptRequest +import com.itsaky.androidide.plugins.services.LlmInferenceService.ToolDefinition + +/** + * Chooses the system prompt for one run: the prompt config's, or the backend's own with the IDE CONTEXT + * block appended. Knows no wording and no file names; see [SystemPromptRenderer]. + * + * @param config supplies the cached prompt config. + * @param ideContext reads what the IDE has open. + * @param backend answers for the backend this run is against. + * @param session reads the clock, fresh for every run. + * @param terminalTool the name of the tool that ends a run by answering the user. + * @param toolCallSyntax the envelope this side parses back, taught only under the text protocol. + */ +class SystemPromptFactory( + private val config: PromptConfigProvider, + private val ideContext: IdeContextSource, + private val backend: BackendPrompts, + private val session: () -> SessionContext, + private val terminalTool: String, + private val toolCallSyntax: String, +) { + + /** + * Builds the prompt; a backend with no prompt of its own gets the general one instead. + * + * @param tools the definitions this run offers, from [PromptToolCatalog], also sent natively. + * @return the prompt to send as the run's system prompt. + */ + suspend fun create(tools: List): String { + val loaded = config.config() + // One editor read serves both the IDE CONTEXT block and the paths in the examples. + val context = ideContext.read() + val session = session() + val request = SystemPromptRequest( + tools, + // Null tells the backend this side parses no envelope; see SystemPromptRequest. + toolCallSyntax.takeUnless { backend.callsToolsNatively() }, + context.exampleFilePath, + ) + + val own = backend.systemPrompt(request) + ?: return SystemPromptRenderer.render(loaded, request, terminalTool, context, session) + return listOf(own, SystemPromptRenderer.renderIdeContext(loaded, context, session)) + .filter { it.isNotEmpty() } + .joinToString(SEPARATOR) + } + + private companion object { + /** What separates a backend's own prompt from the IDE CONTEXT block. */ + const val SEPARATOR = "\n\n" + } +} diff --git a/plugins/AI-Core/src/main/kotlin/com/itsaky/androidide/plugins/aicore/prompt/SystemPromptRenderer.kt b/plugins/AI-Core/src/main/kotlin/com/itsaky/androidide/plugins/aicore/prompt/SystemPromptRenderer.kt new file mode 100644 index 00000000..df29a812 --- /dev/null +++ b/plugins/AI-Core/src/main/kotlin/com/itsaky/androidide/plugins/aicore/prompt/SystemPromptRenderer.kt @@ -0,0 +1,91 @@ +package com.itsaky.androidide.plugins.aicore.prompt + +import com.itsaky.androidide.plugins.ai.prompt.PromptTemplateEngine +import com.itsaky.androidide.plugins.aicore.prompt.config.AgentPromptConfig +import com.itsaky.androidide.plugins.services.LlmInferenceService.SystemPromptRequest +import com.itsaky.androidide.plugins.services.LlmInferenceService.ToolDefinition + +/** + * Renders the prompt from `layout.yml`'s layouts in one pass. Knows no wording: that is the config's, + * and the values are [PromptVariables]'. Pure and thread-safe. + */ +object SystemPromptRenderer { + + /** + * Renders `layout.system_prompt`, the whole prompt for a backend that supplies none of its own. + * + * @param config the loaded prompt config. + * @param request the tools, envelope syntax and example path this run needs described. + * @param terminalTool the name of the tool that ends a run by answering the user. + * @param context what the IDE has open. + * @param session the clock. + * @return the system prompt. + */ + fun render( + config: AgentPromptConfig, + request: SystemPromptRequest, + terminalTool: String, + context: IdeContext, + session: SessionContext, + ): String = PromptTemplateEngine.render( + config.layout.systemPrompt, + PromptVariables.collect(config, request, terminalTool, context, session), + ).trimEnd() + + /** + * Renders `layout.ide_context` alone, to append to a backend's own prompt. + * + * @param config the loaded prompt config. + * @param context what the IDE has open. + * @param session the clock. + * @return the block; the session lines alone when the IDE has nothing open. + */ + fun renderIdeContext(config: AgentPromptConfig, context: IdeContext, session: SessionContext): String = + PromptTemplateEngine.render( + config.layout.ideContext, + PromptVariables.ideContext(config, context, session), + ).trimEnd() + + /** + * Renders both layouts against runs that open and close every section, to catch a name typo. + * + * @param config the loaded prompt config. + * @return one message per distinct failure; empty when every run renders. + */ + fun problems(config: AgentPromptConfig): List = + CHECK_RUNS.flatMap { (request, context, session) -> + listOf( + { render(config, request, CHECK_TERMINAL_TOOL, context, session) }, + { renderIdeContext(config, context, session) }, + ).mapNotNull { run -> + try { + run() + null + } catch (e: IllegalArgumentException) { + e.message + } + } + }.distinct() + + private const val CHECK_TERMINAL_TOOL = "respond" + + /** Two of everything, so a `{{^FIRST}}` inside a list is reached too; and nothing at all. */ + private val CHECK_RUNS: List> = run { + val tools = listOf( + ToolDefinition("read_file", "Read a file.", emptyMap()), + ToolDefinition("respond", "Reply.", emptyMap()), + ) + val full = ProjectLayout.Module("app", "app/src", "app/res/layout", "app/AndroidManifest.xml") + val bare = ProjectLayout.Module("lib", null, null, null) + val session = SessionContext("Monday, 1 January 2029, 09:00 (UTC, UTC+00:00)") + listOf( + Triple( + SystemPromptRequest(tools, "…", "app/Main.kt"), + IdeContext("app/Main.kt", listOf("lib/A.kt", "lib/B.kt"), listOf(full, bare)), + session, + ), + Triple(SystemPromptRequest(emptyList(), null, null), IdeContext(null, emptyList(), listOf(bare)), session), + Triple(SystemPromptRequest(emptyList(), null, null), IdeContext.EMPTY, session), + ) + } +} diff --git a/plugins/AI-Core/src/main/kotlin/com/itsaky/androidide/plugins/aicore/prompt/ToolDescriptions.kt b/plugins/AI-Core/src/main/kotlin/com/itsaky/androidide/plugins/aicore/prompt/ToolDescriptions.kt new file mode 100644 index 00000000..a72db59f --- /dev/null +++ b/plugins/AI-Core/src/main/kotlin/com/itsaky/androidide/plugins/aicore/prompt/ToolDescriptions.kt @@ -0,0 +1,141 @@ +package com.itsaky.androidide.plugins.aicore.prompt + +import com.itsaky.androidide.plugins.ai.prompt.PromptTemplateEngine +import com.itsaky.androidide.plugins.ai.prompt.PromptText +import com.itsaky.androidide.plugins.aicore.prompt.config.AgentPromptConfig +import com.itsaky.androidide.plugins.aicore.prompt.config.AgentPromptConfig.ToolText +import com.itsaky.androidide.plugins.aicore.tool.ToolHandler +import com.itsaky.androidide.plugins.aicore.tool.sources.ContributedToolHandler +import com.itsaky.androidide.plugins.services.LlmInferenceService.ToolDefinition + +/** + * Words ai-core's own tools from `tool_descriptions.yml`: the code declares a built-in's name and + * argument shape, the config says what each is for. A contributed tool brings its own description + * and passes through untouched. Strict: an undescribed built-in or argument throws. + */ +object ToolDescriptions { + + /** + * Fills in the wording of every built-in definition; the others are returned as they are. + * + * @param config the loaded prompt config. + * @param terminalTool the name of the tool that ends a run by answering the user. + * @param definitions the run's definitions, built-in ones carrying no wording yet. + * @param builtInNames which of [definitions] are ai-core's own. + * @return the definitions to hand the backend and describe in the prompt. + */ + fun apply( + config: AgentPromptConfig, + terminalTool: String, + definitions: List, + builtInNames: Set, + ): List = definitions.map { definition -> + if (definition.name in builtInNames) { + describe(definition, builtIn(config, definition.name), terminalTool) + } else { + definition + } + } + + /** + * The terminal tool's definition, worded by `terminal_tool`. + * + * @param config the loaded prompt config. + * @param terminalTool the name the run gives it. + * @param parametersSchema its argument shape, which the code owns. + * @return the definition. + */ + fun terminalTool( + config: AgentPromptConfig, + terminalTool: String, + parametersSchema: Map, + ): ToolDefinition = + describe(ToolDefinition(terminalTool, "", parametersSchema), config.terminalTool, terminalTool) + + /** + * What the approval dialog says a tool does: the same description the model reads. + * + * @param config the loaded prompt config. + * @param terminalTool the name of the tool that ends a run by answering the user. + * @param handler the tool being approved. + * @return its description. + */ + fun describe(config: AgentPromptConfig, terminalTool: String, handler: ToolHandler): String { + if (handler is ContributedToolHandler) return handler.description + return render(builtIn(config, handler.toolName).description, terminalTool) + } + + /** + * Checks the config against the built-in tools, to catch a missing, stray or misspelled entry. + * + * @param config the loaded prompt config. + * @param builtIns ai-core's own handlers. + * @return one message per problem; empty when every tool and argument is described. + */ + fun problems(config: AgentPromptConfig, builtIns: List): List { + val names = builtIns.mapTo(mutableSetOf()) { it.toolName } + val stray = (config.builtInTools.byName.keys - names).map { name -> + "${config.builtInTools.label}.$name: no built-in tool is named $name" + } + val checks: List<() -> Any> = builtIns.map { handler -> + { apply(config, CHECK_TERMINAL_TOOL, listOf(definitionOf(handler)), names) } + } + { terminalTool(config, CHECK_TERMINAL_TOOL, PromptToolCatalog.TERMINAL_TOOL_SCHEMA) } + val failures = checks.mapNotNull { check -> + try { + check() + null + } catch (e: IllegalArgumentException) { + e.message + } + } + return stray + failures + } + + private fun definitionOf(handler: ToolHandler) = + ToolDefinition(handler.toolName, handler.description, handler.parametersSchema) + + private fun builtIn(config: AgentPromptConfig, name: String): ToolText = + requireNotNull(config.builtInTools.byName[name]) { + "${config.builtInTools.label}.$name is missing; every built-in tool needs a description" + } + + /** The definition with [text]'s wording; the argument shape stays as the code declared it. */ + private fun describe( + definition: ToolDefinition, + text: ToolText, + terminalTool: String, + ): ToolDefinition { + val schema: Map = definition.parametersSchema.orEmpty() + val properties = (schema["properties"] as? Map<*, *>).orEmpty() + val declared = properties.keys.map { it.toString() } + // The tool's own label, e.g. `tool_descriptions.yml: built_in_tools.read_file`. + val toolLabel = text.description.label.substringBeforeLast('.') + declared.firstOrNull { it !in text.arguments }?.let { argument -> + throw IllegalArgumentException("$toolLabel.arguments.$argument is missing") + } + text.arguments.entries.firstOrNull { it.key !in declared }?.let { (argument, stray) -> + throw IllegalArgumentException("${stray.label}: ${definition.name} takes no argument $argument") + } + val described = if (properties.isEmpty()) { + schema + } else { + schema + ("properties" to describeArguments(properties, text, terminalTool)) + } + return ToolDefinition(definition.name, render(text.description, terminalTool), described) + } + + /** Each argument's schema with its description added, in the order the code declared them. */ + private fun describeArguments( + properties: Map<*, *>, + text: ToolText, + terminalTool: String, + ): Map = properties.entries.associate { (name, property) -> + val description = render(text.arguments.getValue(name.toString()), terminalTool) + name.toString() to ((property as Map<*, *>) + ("description" to description)) + } + + private fun render(text: PromptText, terminalTool: String): String = + PromptTemplateEngine.render(text, mapOf(PromptVariables.TERMINAL_TOOL to terminalTool)) + + private const val CHECK_TERMINAL_TOOL = "respond" +} diff --git a/plugins/AI-Core/src/main/kotlin/com/itsaky/androidide/plugins/aicore/prompt/ToolResultsPrompt.kt b/plugins/AI-Core/src/main/kotlin/com/itsaky/androidide/plugins/aicore/prompt/ToolResultsPrompt.kt new file mode 100644 index 00000000..9ceee4ae --- /dev/null +++ b/plugins/AI-Core/src/main/kotlin/com/itsaky/androidide/plugins/aicore/prompt/ToolResultsPrompt.kt @@ -0,0 +1,172 @@ +package com.itsaky.androidide.plugins.aicore.prompt + +import com.itsaky.androidide.plugins.ai.prompt.PromptConfigProvider +import com.itsaky.androidide.plugins.ai.prompt.PromptTemplateEngine +import com.itsaky.androidide.plugins.aicore.models.ToolResult +import com.itsaky.androidide.plugins.aicore.prompt.config.AgentPromptConfig +import com.itsaky.androidide.plugins.aicore.tool.ToolCall +import com.itsaky.androidide.plugins.aicore.tool.ToolResultsFormatter +import com.itsaky.androidide.plugins.aicore.tool.web.WebAccess + +/** + * The turn after each tool batch: the results in `` envelopes, then what to do next, + * worded by `agent_loop.yml` and arranged by `layout.tool_results`. The envelope stays in code, + * since chat-tuned models are trained on the tag; handed bare prose they re-issue the call. + * + * @param config supplies the cached prompt config. + * @param terminalTool the name of the tool that ends a run by answering the user. + * @param charLimit each result's cap, so a big output does not blow a local model's context. + */ +class ToolResultsPrompt( + private val config: PromptConfigProvider, + private val terminalTool: String, + private val charLimit: Int = DEFAULT_CHAR_LIMIT, +) : ToolResultsFormatter { + + override suspend fun format(calls: List, results: List): String = + render(config.config(), terminalTool, charLimit, calls, results) + + /** The turn sent after a reply that ran tools and ended without the terminal tool. */ + suspend fun unfinished(): String = renderUnfinished(config.config(), terminalTool) + + /** The turn sent after a reply that answered before calling [tool], which the run had to call first. */ + suspend fun requiredTool(tool: String): String = renderRequiredTool(config.config(), terminalTool, tool) + + companion object { + /** Per-result cap fed back into the prompt, so big outputs don't blow a local model's context. */ + const val DEFAULT_CHAR_LIMIT = 4000 + + /** + * A search report's cap: [DEFAULT_CHAR_LIMIT] cut a full report before the Sources list the + * backend appends to it, leaving the agent nothing to cite. No local backend can search. + */ + const val WEB_SEARCH_CHAR_LIMIT = 12000 + + /** + * Renders one batch's turn. Pure and thread-safe. + * + * @param config the loaded prompt config. + * @param terminalTool the name of the tool that ends a run by answering the user. + * @param charLimit each result's cap; a web search's is at least [WEB_SEARCH_CHAR_LIMIT]. + * @param calls the tool calls that ran. + * @param results their results, positionally aligned with [calls]. + * @return the turn to add to the transcript. + */ + fun render( + config: AgentPromptConfig, + terminalTool: String, + charLimit: Int, + calls: List, + results: List, + ): String { + val responses = buildString { + results.forEachIndexed { index, result -> + val name = calls.getOrNull(index)?.name ?: "tool" + val limit = + if (name == WebAccess.WEB_SEARCH_TOOL) maxOf(charLimit, WEB_SEARCH_CHAR_LIMIT) else charLimit + val body = truncate(config, terminalTool, body(config, terminalTool, result), limit) + append("\n[").append(name).append("] ").append(body) + append("\n\n\n") + } + } + val allSucceeded = results.isNotEmpty() && results.all { it.success } + return PromptTemplateEngine.render( + config.layout.toolResults, + PromptVariables.toolResults(config, terminalTool, responses, allSucceeded), + ) + } + + /** + * Renders the turn that asks a run which ended without the terminal tool to finish it. + * + * @param config the loaded prompt config. + * @param terminalTool the name of the tool that ends a run by answering the user. + * @return the turn to add to the transcript. + */ + fun renderUnfinished(config: AgentPromptConfig, terminalTool: String): String = + PromptTemplateEngine.render( + config.agentLoop.unfinished, + mapOf(PromptVariables.TERMINAL_TOOL to terminalTool), + ) + + /** + * Renders the turn that asks a run which answered too early to call [tool] first. + * + * @param config the loaded prompt config. + * @param terminalTool the name of the tool that ends a run by answering the user. + * @param tool the tool the run had to call before answering. + * @return the turn to add to the transcript. + */ + fun renderRequiredTool(config: AgentPromptConfig, terminalTool: String, tool: String): String = + PromptTemplateEngine.render( + config.agentLoop.requiredTool, + mapOf(PromptVariables.TERMINAL_TOOL to terminalTool, PromptVariables.TOOL to tool), + ) + + /** + * Renders every turn this class words, reaching each text and section, to catch a name typo. + * + * @param config the loaded prompt config. + * @return one message per distinct failure; empty when every batch renders. + */ + fun problems(config: AgentPromptConfig): List { + val renders: List<() -> String> = CHECK_BATCHES.map { (calls, results) -> + { render(config, CHECK_TERMINAL_TOOL, CHECK_CHAR_LIMIT, calls, results) } + } + { renderUnfinished(config, CHECK_TERMINAL_TOOL) } + + { renderRequiredTool(config, CHECK_TERMINAL_TOOL, CHECK_REQUIRED_TOOL) } + return renders.mapNotNull { check -> + try { + check() + null + } catch (e: IllegalArgumentException) { + e.message + } + }.distinct() + } + + /** The result's own text: its message, then its data or, for a failure, its details. */ + private fun body(config: AgentPromptConfig, terminalTool: String, result: ToolResult): String { + val content = buildString { + append(result.message) + val extra = if (result.success) result.data else result.error_details + extra?.takeIf { it.isNotBlank() }?.let { append("\n").append(it) } + } + if (result.success) return content + val values = mapOf( + PromptVariables.TERMINAL_TOOL to terminalTool, + PromptVariables.MESSAGE to content, + ) + return PromptTemplateEngine.render(config.agentLoop.failed, values) + } + + private fun truncate( + config: AgentPromptConfig, + terminalTool: String, + text: String, + limit: Int, + ): String { + if (text.length <= limit) return text + val values = mapOf( + PromptVariables.TERMINAL_TOOL to terminalTool, + PromptVariables.KEPT to text.take(limit), + PromptVariables.COUNT to (text.length - limit).toString(), + ) + return PromptTemplateEngine.render(config.agentLoop.truncated, values) + } + + private const val CHECK_TERMINAL_TOOL = "respond" + private const val CHECK_REQUIRED_TOOL = "web_search" + + /** Small enough that the check batches' results are truncated too. */ + private const val CHECK_CHAR_LIMIT = 8 + + /** A mixed batch of two, so a list section is reached twice; and one that succeeds. */ + private val CHECK_BATCHES: List, List>> = listOf( + listOf(ToolCall("read_file", emptyMap()), ToolCall("open_file", emptyMap())) to listOf( + ToolResult.success("read", "x".repeat(20)), + ToolResult.failure("not found", "detail"), + ), + listOf(ToolCall("open_file", emptyMap())) to listOf(ToolResult.success("opened")), + ) + } +} diff --git a/plugins/AI-Core/src/main/kotlin/com/itsaky/androidide/plugins/aicore/prompt/config/AgentPromptConfig.kt b/plugins/AI-Core/src/main/kotlin/com/itsaky/androidide/plugins/aicore/prompt/config/AgentPromptConfig.kt new file mode 100644 index 00000000..226ef28d --- /dev/null +++ b/plugins/AI-Core/src/main/kotlin/com/itsaky/androidide/plugins/aicore/prompt/config/AgentPromptConfig.kt @@ -0,0 +1,172 @@ +package com.itsaky.androidide.plugins.aicore.prompt.config + +import com.itsaky.androidide.plugins.ai.prompt.PromptConfigLoader +import com.itsaky.androidide.plugins.ai.prompt.PromptText + + +/** + * The agent's prompt as `assets/prompts/` declares it: the wording, and the layout that arranges + * it. Loaded by [PromptConfigLoader]; immutable, so one instance serves every chat turn. + * + * @property identity who the agent is and what it will answer. + * @property rules the rules, highest priority first. + * @property tools the wording around the tool list. + * @property toolCallFormat how to write a tool call as text, taught only under the text protocol. + * @property ideContext the wording of the IDE CONTEXT block. + * @property session the wording of the run's own facts: the clock, and that the web is reachable. + * @property agentLoop what the agent is told after each tool batch. + * @property approval what the agent is told when the user does not approve a tool call. + * @property contextFiles the wording around the files the user attached. + * @property chatTitle what a backend is told when it is asked to name a chat. + * @property terminalTool what the tool the agent answers the user with is for. + * @property builtInTools what each of ai-core's own tools is for. + * @property webSearch what a backend is told when the agent asks it to search the web. + * @property answerReview what a backend is told when it checks an answer holding code. + * @property layout where each text goes. + */ +data class AgentPromptConfig( + val identity: PromptText, + val rules: List, + val tools: Tools, + val toolCallFormat: ToolCallFormat, + val ideContext: IdeContextText, + val session: SessionText, + val agentLoop: AgentLoopText, + val approval: ApprovalText, + val contextFiles: ContextFilesText, + val chatTitle: ChatTitleText, + val terminalTool: ToolText, + val builtInTools: BuiltInTools, + val webSearch: WebSearchText, + val answerReview: AnswerReviewText, + val layout: Layout, +) { + + /** + * One priority's rules. + * + * @property heading the priority's name, e.g. `CRITICAL`. + * @property items the rules, one sentence each. + */ + data class RuleGroup(val heading: PromptText, val items: List) + + /** @property heading what introduces the tool list. */ + data class Tools(val heading: PromptText) + + /** + * @property instruction the sentence introducing the envelope. + * @property exampleHeading what introduces [example]. + * @property example one well-formed call. + */ + data class ToolCallFormat( + val instruction: PromptText, + val exampleHeading: PromptText, + val example: PromptText, + ) + + /** One line per fact the IDE can state; see `ide_context.yml` for what each says. */ + data class IdeContextText( + val heading: PromptText, + val currentFile: PromptText, + val otherFiles: PromptText, + val moduleSourceDir: PromptText, + val moduleLayoutDir: PromptText, + val moduleManifest: PromptText, + val modulesKnown: PromptText, + val closing: PromptText, + ) + + /** + * @property currentTime states the device's date and time. + * @property webAccess says the web tools are there to be used. + */ + data class SessionText( + val currentTime: PromptText, + val webAccess: PromptText, + ) + + /** + * One tool's wording, as its definition carries it to the model. + * + * @property description what the tool does. + * @property arguments what each argument means, keyed by argument name; empty for none. + */ + data class ToolText(val description: PromptText, val arguments: Map) + + /** + * @property byName each built-in tool's wording, keyed by tool name. + * @property label where they are declared, e.g. `tool_descriptions.yml: built_in_tools`. + */ + data class BuiltInTools(val byName: Map, val label: String) + + /** + * @property failed a failed tool's result, around its message. + * @property truncated a result cut to the size limit. + * @property grounding what every tool-results turn asks of the next reply. + * @property afterSuccess what to do next when every tool in the batch succeeded. + * @property afterFailure what to do next otherwise. + * @property unfinished the turn after a reply that ran tools and then ended without the + * terminal tool. + * @property requiredTool the turn after a reply that answered before calling the tool the run + * had to call first, such as a web search before a code review. + */ + data class AgentLoopText( + val failed: PromptText, + val truncated: PromptText, + val grounding: PromptText, + val afterSuccess: PromptText, + val afterFailure: PromptText, + val unfinished: PromptText, + val requiredTool: PromptText, + ) + + /** + * @property denied the user refused the call. + * @property corrected the user asked for a revision without saying what. + * @property correctedWithInstruction the user asked for a revision and said what. + * @property timedOut nobody answered the dialog in time. + */ + data class ApprovalText( + val denied: PromptText, + val corrected: PromptText, + val correctedWithInstruction: PromptText, + val timedOut: PromptText, + ) + + /** @property heading what introduces the attached files. */ + data class ContextFilesText(val heading: PromptText) + + /** @property instruction the system prompt of a chat-title request. */ + data class ChatTitleText(val instruction: PromptText) + + /** @property instruction the system prompt of a web search request. */ + data class WebSearchText(val instruction: PromptText) + + /** + * @property instruction the system prompt of an answer review request. + * @property noEvidence what the review is shown in place of the evidence when no tool ran. + */ + data class AnswerReviewText(val instruction: PromptText, val noEvidence: PromptText) + + /** + * @property systemPrompt the whole prompt, for a backend that supplies none of its own. + * @property ideContext the IDE CONTEXT block, also appended to a backend's own prompt. + * @property toolResults the turn that carries a tool batch's results back to the model. + * @property contextFiles the attached files, appended to the user's message. + * @property chatTitle the user turn of a chat-title request. + * @property answerReview the user turn of an answer review request. + */ + data class Layout( + val systemPrompt: PromptText, + val ideContext: PromptText, + val toolResults: PromptText, + val contextFiles: PromptText, + val chatTitle: PromptText, + val answerReview: PromptText, + ) + + companion object { + /** The `schema_version` this code reads; bump it when a key is renamed or removed. */ + const val SCHEMA_VERSION = 2 + } +} diff --git a/plugins/AI-Core/src/main/kotlin/com/itsaky/androidide/plugins/aicore/prompt/config/AgentPromptConfigParser.kt b/plugins/AI-Core/src/main/kotlin/com/itsaky/androidide/plugins/aicore/prompt/config/AgentPromptConfigParser.kt new file mode 100644 index 00000000..d397714b --- /dev/null +++ b/plugins/AI-Core/src/main/kotlin/com/itsaky/androidide/plugins/aicore/prompt/config/AgentPromptConfigParser.kt @@ -0,0 +1,111 @@ +package com.itsaky.androidide.plugins.aicore.prompt.config + +import com.itsaky.androidide.plugins.ai.prompt.PromptConfigDocument +import com.itsaky.androidide.plugins.ai.prompt.PromptConfigException +import com.itsaky.androidide.plugins.ai.prompt.PromptConfigLoader +import com.itsaky.androidide.plugins.ai.prompt.PromptConfigObject +import com.itsaky.androidide.plugins.ai.prompt.PromptConfigParser +import com.itsaky.androidide.plugins.aicore.prompt.config.AgentPromptConfig.AgentLoopText +import com.itsaky.androidide.plugins.aicore.prompt.config.AgentPromptConfig.AnswerReviewText +import com.itsaky.androidide.plugins.aicore.prompt.config.AgentPromptConfig.ApprovalText +import com.itsaky.androidide.plugins.aicore.prompt.config.AgentPromptConfig.BuiltInTools +import com.itsaky.androidide.plugins.aicore.prompt.config.AgentPromptConfig.ChatTitleText +import com.itsaky.androidide.plugins.aicore.prompt.config.AgentPromptConfig.ContextFilesText +import com.itsaky.androidide.plugins.aicore.prompt.config.AgentPromptConfig.IdeContextText +import com.itsaky.androidide.plugins.aicore.prompt.config.AgentPromptConfig.Layout +import com.itsaky.androidide.plugins.aicore.prompt.config.AgentPromptConfig.RuleGroup +import com.itsaky.androidide.plugins.aicore.prompt.config.AgentPromptConfig.SessionText +import com.itsaky.androidide.plugins.aicore.prompt.config.AgentPromptConfig.ToolCallFormat +import com.itsaky.androidide.plugins.aicore.prompt.config.AgentPromptConfig.ToolText +import com.itsaky.androidide.plugins.aicore.prompt.config.AgentPromptConfig.Tools +import com.itsaky.androidide.plugins.aicore.prompt.config.AgentPromptConfig.WebSearchText + +/** + * Maps the merged config onto an [AgentPromptConfig]. Strict: a missing, mistyped or unknown key + * throws [PromptConfigException] naming the file that holds it, so a typo fails on activation. + */ +object AgentPromptConfigParser : PromptConfigParser { + + /** + * Parses the config merged from `agent.yml` and its includes; see [PromptConfigLoader]. + * + * @param document the merged top-level keys and the file each came from. + * @return the config. + */ + override fun parse(document: PromptConfigDocument): AgentPromptConfig = + document.read { + val version = int("schema_version") + if (version != AgentPromptConfig.SCHEMA_VERSION) { + val supported = AgentPromptConfig.SCHEMA_VERSION + throw invalid("schema_version", "is $version, but this ai-core reads $supported") + } + AgentPromptConfig( + identity = text("identity"), + rules = objects("rules").map { it.read { RuleGroup(text("heading"), texts("items")) } }, + tools = obj("tools").read { Tools(text("heading")) }, + toolCallFormat = obj("tool_call_format").read { + ToolCallFormat(text("instruction"), text("example_heading"), text("example")) + }, + ideContext = obj("ide_context").read { + IdeContextText( + heading = text("heading"), + currentFile = text("current_file"), + otherFiles = text("other_files"), + moduleSourceDir = text("module_source_dir"), + moduleLayoutDir = text("module_layout_dir"), + moduleManifest = text("module_manifest"), + modulesKnown = text("modules_known"), + closing = text("closing"), + ) + }, + session = obj("session").read { + SessionText( + currentTime = text("current_time"), + webAccess = text("web_access"), + ) + }, + agentLoop = obj("agent_loop").read { + AgentLoopText( + failed = text("failed"), + truncated = text("truncated"), + grounding = text("grounding"), + afterSuccess = text("after_success"), + afterFailure = text("after_failure"), + unfinished = text("unfinished"), + requiredTool = text("required_tool"), + ) + }, + approval = obj("approval").read { + ApprovalText( + denied = text("denied"), + corrected = text("corrected"), + correctedWithInstruction = text("corrected_with_instruction"), + timedOut = text("timed_out"), + ) + }, + contextFiles = obj("context_files").read { ContextFilesText(text("heading")) }, + chatTitle = obj("chat_title").read { ChatTitleText(text("instruction")) }, + terminalTool = obj("terminal_tool").read { toolText() }, + builtInTools = BuiltInTools( + objectEntries("built_in_tools").mapValues { (_, tool) -> tool.read { toolText() } }, + labelOf("built_in_tools"), + ), + webSearch = obj("web_search").read { WebSearchText(text("instruction")) }, + answerReview = obj("answer_review").read { + AnswerReviewText(text("instruction"), text("no_evidence")) + }, + layout = obj("layout").read { + Layout( + text("system_prompt"), + text("ide_context"), + text("tool_results"), + text("context_files"), + text("chat_title"), + text("answer_review"), + ) + }, + ) + } + + private fun PromptConfigObject.toolText() = ToolText(text("description"), optionalTextEntries("arguments")) +} diff --git a/plugins/AI-Core/src/main/kotlin/com/itsaky/androidide/plugins/aicore/prompt/config/SharedPromptConfig.kt b/plugins/AI-Core/src/main/kotlin/com/itsaky/androidide/plugins/aicore/prompt/config/SharedPromptConfig.kt new file mode 100644 index 00000000..6cbee92b --- /dev/null +++ b/plugins/AI-Core/src/main/kotlin/com/itsaky/androidide/plugins/aicore/prompt/config/SharedPromptConfig.kt @@ -0,0 +1,8 @@ +package com.itsaky.androidide.plugins.aicore.prompt.config + +import com.itsaky.androidide.plugins.ai.prompt.PromptConfigStore + + + +/** This plugin's prompt config, filled on activation and read by every chat turn. */ +val sharedPromptConfig: PromptConfigStore = PromptConfigStore(AgentPromptConfigParser) diff --git a/plugins/AI-Core/src/main/kotlin/com/itsaky/androidide/plugins/aicore/tool/AgentLoop.kt b/plugins/AI-Core/src/main/kotlin/com/itsaky/androidide/plugins/aicore/tool/AgentLoop.kt index 312933c3..3ec7dd56 100644 --- a/plugins/AI-Core/src/main/kotlin/com/itsaky/androidide/plugins/aicore/tool/AgentLoop.kt +++ b/plugins/AI-Core/src/main/kotlin/com/itsaky/androidide/plugins/aicore/tool/AgentLoop.kt @@ -8,17 +8,25 @@ import com.itsaky.androidide.plugins.services.LlmInferenceService.ChatMessage.Ro /** * 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. + * Free of Android/coroutine/UI deps so it unit-tests with plain fakes; knows no wording. + * + * @param formatToolResults words each batch's results as the next user turn. + * @param unfinishedTurn words the turn sent when a run that has used tools ends a reply without + * [terminalTool]; null ends the run on that reply instead. + * @param requiredToolTurn words the turn sent when a run told to call a tool first tries to finish + * without it; receives the tool's name. Null never asks. */ class AgentLoop( + private val formatToolResults: ToolResultsFormatter, 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 maxTurnsWithoutProgress: Int = DEFAULT_MAX_TURNS_WITHOUT_PROGRESS, private val extractToolCalls: (String) -> List = ToolCallExtractor::extractToolCalls, private val diagnoseUnparsedReply: (String) -> ToolCallExtractor.UnparsedReply? = ToolCallExtractor::diagnoseUnparsedReply, private val terminalTool: String? = null, + private val unfinishedTurn: (suspend () -> String)? = null, + private val requiredToolTurn: (suspend (String) -> String)? = null, ) { companion object { @@ -29,9 +37,6 @@ class AgentLoop( */ const val DEFAULT_MAX_ITERATIONS = 16 - /** 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. @@ -134,6 +139,23 @@ class AgentLoop( * @param turn 1-based turn index. */ suspend fun onAbandonedAfterFailure(turn: Int) {} + + /** + * A run that has used tools replied without the terminal tool, so it was asked to finish + * or carry on rather than being ended on that reply. + * + * @param turn 1-based turn index. + */ + suspend fun onUnfinishedReply(turn: Int) {} + + /** + * The run was told to call [tool] before answering and tried to finish without it, so it + * was asked to call it; a backend that forces the call never reaches this. + * + * @param turn 1-based turn index. + * @param tool the tool the run had to call. + */ + suspend fun onRequiredToolSkipped(turn: Int, tool: String) {} } /** Why the loop stopped. */ @@ -178,6 +200,8 @@ class AgentLoop( * @param pathsOf the project paths a call names; the progress guard counts an earlier look at * one a later call rewrote as a new action again rather than as a repeat. * @param changesPaths whether a call rewrites what it names. + * @param requiredTool a tool the run must call before it may finish, e.g. a web search before a + * code review; asked for once through [requiredToolTurn]. Null requires nothing. * @param events UI/state callbacks. * @return the run [Result]. */ @@ -187,6 +211,7 @@ class AgentLoop( executeTools: suspend (List) -> List, pathsOf: (ToolCall) -> Set = { emptySet() }, changesPaths: (ToolCall) -> Boolean = { false }, + requiredTool: String? = null, events: Events = object : Events {}, ): Result { var turn = 0 @@ -196,6 +221,11 @@ class AgentLoop( pathsOf, changesPaths, ) + // Once a tool has run, only the terminal tool finishes the run; see [unfinishedTurn]. + var toolsRan = false + var askedToFinish = false + // Cleared once the tool runs or has been asked for, so a model that refuses it still stops. + var requiredPending = requiredTool != null && requiredToolTurn != null while (turn < maxIterations) { turn++ @@ -218,6 +248,18 @@ class AgentLoop( events.onUnparsedReply(turn, unparsed) return Result(turn, StopReason.UNPARSABLE) } + if (requiredPending) { + requiredPending = false + askForRequiredTool(turn, requiredTool!!, history, events) + continue + } + // Asked once per stretch of prose, so a model that will not call it still stops. + if (toolsRan && !askedToFinish && unfinishedTurn != null) { + askedToFinish = true + events.onUnfinishedReply(turn) + history.add(ChatMessage(Role.USER, unfinishedTurn.invoke())) + continue + } // Prose after a failed batch is the model giving up, not finishing: the run ends // with the user's request unmet, so reporting it COMPLETED overstates the outcome. if (progress.lastBatchFailed) { @@ -231,6 +273,12 @@ class AgentLoop( val realCalls = terminalTool?.let { tt -> calls.filterNot { isTerminalToolName(it.name, tt) } } ?: calls + // Only the terminal tool: an answer, which a run owing its required tool may not give yet. + if (realCalls.isEmpty() && requiredPending) { + requiredPending = false + askForRequiredTool(turn, requiredTool!!, history, events) + continue + } terminalTool?.let { tt -> val terminal = calls.firstOrNull { isTerminalToolName(it.name, tt) } if (terminal != null && realCalls.isEmpty()) { @@ -256,15 +304,29 @@ class AgentLoop( } val results = executeTools(realCalls) + if (realCalls.any { it.name == requiredTool }) requiredPending = false + toolsRan = true + askedToFinish = false progress.recordResults(results) events.onToolResults(turn, realCalls, results) - history.add(ChatMessage(Role.USER, formatToolResults(realCalls, results))) + history.add(ChatMessage(Role.USER, formatToolResults.format(realCalls, results))) } events.onMaxIterationsReached(turn) return Result(turn, StopReason.MAX_ITERATIONS) } + /** Records the reply as not finishing the run and asks for [tool] in the next user turn. */ + private suspend fun askForRequiredTool( + turn: Int, + tool: String, + history: MutableList, + events: Events, + ) { + events.onRequiredToolSkipped(turn, tool) + history.add(ChatMessage(Role.USER, requiredToolTurn!!.invoke(tool))) + } + /** * Flattens the transcript into one prompt string, with no trailing "Assistant:" cue, which the * backend appends itself. Only for backends whose transport carries a single string; one that @@ -283,51 +345,4 @@ class AgentLoop( } return sb.toString() } - - /** - * Renders tool results for the next prompt, capping each body and wrapping it in - * `` tags that chat-tuned models are trained to read. Handed the same content as - * bare prose, a small model tends to re-issue the call it already ran. - * @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() - 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("\n") - .append("[").append(name).append("] ").append(truncate(body)).append("\n") - .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/plugins/AI-Core/src/main/kotlin/com/itsaky/androidide/plugins/aicore/tool/ToolApprovalManager.kt b/plugins/AI-Core/src/main/kotlin/com/itsaky/androidide/plugins/aicore/tool/ToolApprovalManager.kt index c73bcbb0..51c2ca64 100644 --- a/plugins/AI-Core/src/main/kotlin/com/itsaky/androidide/plugins/aicore/tool/ToolApprovalManager.kt +++ b/plugins/AI-Core/src/main/kotlin/com/itsaky/androidide/plugins/aicore/tool/ToolApprovalManager.kt @@ -1,8 +1,11 @@ package com.itsaky.androidide.plugins.aicore.tool import android.util.Log +import com.itsaky.androidide.plugins.ai.prompt.PromptConfigProvider import com.itsaky.androidide.plugins.aicore.logging.AgentTrace import com.itsaky.androidide.plugins.aicore.logging.LOG_PREFIX +import com.itsaky.androidide.plugins.aicore.prompt.ApprovalPrompt +import com.itsaky.androidide.plugins.aicore.prompt.config.AgentPromptConfig import java.util.concurrent.ConcurrentHashMap import kotlinx.coroutines.CompletableDeferred import kotlinx.coroutines.flow.MutableStateFlow @@ -16,7 +19,12 @@ import kotlinx.coroutines.withTimeoutOrNull * Manages user approval for tool execution. * Tools that modify system state require explicit user approval. */ -class ToolApprovalManager { +class ToolApprovalManager( + // Words what the model is told when the user does not approve; see agent_loop.yml's approval. + private val config: PromptConfigProvider, + // What the dialog says a tool does; the ViewModel reads a built-in's from the prompt config. + private val describe: suspend (ToolHandler) -> String = { it.description }, +) { private val TAG = "$LOG_PREFIX.ToolApprovalManager" // One source for the wait and the wording, so the message cannot outlive the number. @@ -95,7 +103,7 @@ class ToolApprovalManager { displayName = handler.displayName, sourceLabel = handler.sourceLabel, args = args, - description = handler.description + description = describe(handler) ) val deferred = CompletableDeferred() @@ -124,7 +132,7 @@ class ToolApprovalManager { * @param handler its handler. * @return the response for this call. */ - private fun responseTo( + private suspend fun responseTo( decision: ApprovalDecision?, toolName: String, handler: ToolHandler @@ -145,41 +153,25 @@ class ToolApprovalManager { ApprovalResult.CORRECTED -> { Log.d(TAG, "User requested a correction for $toolName") // Only this attempt is denied; a tool failure is the channel the loop re-feeds. - ApprovalResponse(approved = false, denialMessage = correctionMessage(toolName, decision.correction)) + val message = ApprovalPrompt.corrected(config.config(), toolName, decision.correction.orEmpty()) + ApprovalResponse(approved = false, denialMessage = message) } ApprovalResult.DENIED -> { Log.d(TAG, "Approval denied for $toolName") ApprovalResponse( approved = false, - denialMessage = "User denied permission to execute $toolName" + denialMessage = ApprovalPrompt.denied(config.config(), toolName) ) } null -> { Log.w(TAG, "Approval request timed out after ${APPROVAL_TIMEOUT_MS}ms for $toolName") ApprovalResponse( approved = false, - denialMessage = "Approval request timed out (no response within " + - "$APPROVAL_TIMEOUT_MINUTES minutes). Please try again." + denialMessage = ApprovalPrompt.timedOut(config.config(), toolName, APPROVAL_TIMEOUT_MINUTES) ) } } - /** - * Phrases a correction back to the model as the instruction to apply on the retry. - * @param toolName the tool the user rejected. - * @param correction what the user typed, if anything. - * @return the denial message the loop feeds back. - */ - private fun correctionMessage(toolName: String, correction: String?): String { - val instruction = correction?.trim().orEmpty() - val rejected = "User rejected this $toolName call and asked you to revise it" - return if (instruction.isEmpty()) { - "$rejected." - } else { - "$rejected: \"$instruction\". Apply that instruction and try again." - } - } - /** * Submit user's approval decision. * @param result what the user chose. diff --git a/plugins/AI-Core/src/main/kotlin/com/itsaky/androidide/plugins/aicore/tool/ToolCallExtractor.kt b/plugins/AI-Core/src/main/kotlin/com/itsaky/androidide/plugins/aicore/tool/ToolCallExtractor.kt index e4581820..88b451f7 100644 --- a/plugins/AI-Core/src/main/kotlin/com/itsaky/androidide/plugins/aicore/tool/ToolCallExtractor.kt +++ b/plugins/AI-Core/src/main/kotlin/com/itsaky/androidide/plugins/aicore/tool/ToolCallExtractor.kt @@ -47,6 +47,14 @@ class ToolCallExtractor { */ private val BARE_TOOL_KEY_REGEX = Regex(""""tool"\s*:""") + /** + * A fenced code block, closed or left open by a reply that hit its output cap. + * + * Only ever used to decide whether a `{"tool":…}` shape is a call or something the user + * asked to be shown, never to produce text, so swallowing an unclosed tail is the safe way. + */ + private val FENCED_BLOCK_REGEX = Regex("""```(?:.*?```|.*)""", RegexOption.DOT_MATCHES_ALL) + /** * Classifies a reply that [extractToolCalls] found nothing in. * @@ -71,15 +79,40 @@ class ToolCallExtractor { /** * Whether [text] holds something shaped like a bare `{"tool":…}` call. * - * Requires the key to sit inside an object, so neither the diagnosis nor the prose filter - * fires on a sentence that quotes the word. + * Requires the key to sit inside an object and outside a fenced code block, so neither the + * diagnosis nor the prose filter fires on a sentence that quotes the word, nor on the JSON + * an answer about tool schemas or a config file is made of (ADFA-6223). * * @param text the text to inspect. * @return true when a bare call is present. */ private fun containsBareToolCall(text: String): Boolean { - val key = BARE_TOOL_KEY_REGEX.find(text) ?: return false - return text.lastIndexOf('{', key.range.first) >= 0 + val outsideFences = blankFencedBlocks(text) + // Every match is tried: prose quoting the key ahead of a real call must not hide the call. + return BARE_TOOL_KEY_REGEX.findAll(outsideFences).any { isInsideObject(outsideFences, it.range.first) } + } + + /** + * [text] with every fenced code block overwritten by spaces, so a bare `{"tool":…}` there + * reads as an example rather than a call. Blanked rather than removed: a call after a fence + * must keep an object opening before it, and offsets must still line up with [text]. + */ + private fun blankFencedBlocks(text: String): String = + FENCED_BLOCK_REGEX.replace(text) { " ".repeat(it.value.length) } + + /** + * Whether [position] sits inside an unclosed `{`, by brace depth rather than the nearest + * brace, so a closed object before prose does not count and a nested one inside a call does. + */ + private fun isInsideObject(text: String, position: Int): Boolean { + var depth = 0 + for (i in 0 until position) { + when (text[i]) { + '{' -> depth++ + '}' -> if (depth > 0) depth-- + } + } + return depth > 0 } /** @@ -169,9 +202,9 @@ class ToolCallExtractor { toolCalls.addAll(extractFromXmlTags(body)) if (toolCalls.isNotEmpty()) strategy = "envelope" - // Strategy 2: Bare JSON objects if no XML found + // Strategy 2: Bare JSON objects if no XML found; one inside a code fence is an example. if (toolCalls.isEmpty()) { - toolCalls.addAll(extractFromJsonObjects(body)) + toolCalls.addAll(extractFromJsonObjects(blankFencedBlocks(body))) if (toolCalls.isNotEmpty()) strategy = "bare_json" } @@ -220,7 +253,7 @@ class ToolCallExtractor { /** * Strategy 2: Extract tool calls from bare JSON objects. * Format: {"tool":"name","args":{...}} - * Uses brace-balanced extraction to handle nested args objects. + * Brace-balanced, for nested args; the caller blanks code fences first. */ private fun extractFromJsonObjects(text: String): List { val toolCalls = mutableListOf() diff --git a/plugins/AI-Core/src/main/kotlin/com/itsaky/androidide/plugins/aicore/tool/ToolHandler.kt b/plugins/AI-Core/src/main/kotlin/com/itsaky/androidide/plugins/aicore/tool/ToolHandler.kt index ff6c0d3e..8c68c870 100644 --- a/plugins/AI-Core/src/main/kotlin/com/itsaky/androidide/plugins/aicore/tool/ToolHandler.kt +++ b/plugins/AI-Core/src/main/kotlin/com/itsaky/androidide/plugins/aicore/tool/ToolHandler.kt @@ -12,9 +12,11 @@ interface ToolHandler { val toolName: String /** - * Description of what this tool does. + * What this tool does, for a contributed tool, which brings its own. Empty for a built-in: its + * wording, arguments included, is `tool_descriptions.yml`'s; see `ToolDescriptions`. */ val description: String + get() = "" /** * Execute the tool with the given arguments. diff --git a/plugins/AI-Core/src/main/kotlin/com/itsaky/androidide/plugins/aicore/tool/ToolResultsFormatter.kt b/plugins/AI-Core/src/main/kotlin/com/itsaky/androidide/plugins/aicore/tool/ToolResultsFormatter.kt new file mode 100644 index 00000000..3c8a5daf --- /dev/null +++ b/plugins/AI-Core/src/main/kotlin/com/itsaky/androidide/plugins/aicore/tool/ToolResultsFormatter.kt @@ -0,0 +1,14 @@ +package com.itsaky.androidide.plugins.aicore.tool + +import com.itsaky.androidide.plugins.aicore.models.ToolResult + +/** Words a tool batch's results as the user turn that carries them back to the model. */ +fun interface ToolResultsFormatter { + + /** + * @param calls the tool calls that ran. + * @param results their results, positionally aligned with [calls]. + * @return the turn to add to the transcript. + */ + suspend fun format(calls: List, results: List): String +} diff --git a/plugins/AI-Core/src/main/kotlin/com/itsaky/androidide/plugins/aicore/tool/ToolSchema.kt b/plugins/AI-Core/src/main/kotlin/com/itsaky/androidide/plugins/aicore/tool/ToolSchema.kt index b90336db..7d2223d7 100644 --- a/plugins/AI-Core/src/main/kotlin/com/itsaky/androidide/plugins/aicore/tool/ToolSchema.kt +++ b/plugins/AI-Core/src/main/kotlin/com/itsaky/androidide/plugins/aicore/tool/ToolSchema.kt @@ -29,19 +29,18 @@ object ToolSchema { /** * A string argument. - * @param description what the argument means, as the model will read it. + * @param description what it means, as the model reads it; null for a built-in tool, whose + * argument wording is `tool_descriptions.yml`'s. * @return the property schema. */ - fun string(description: String): Map = - mapOf("type" to "string", "description" to description) + fun string(description: String? = null): Map = property("string", description) /** * A boolean argument. - * @param description what the argument means, as the model will read it. + * @param description what it means, as the model reads it; null for a built-in tool. * @return the property schema. */ - fun boolean(description: String): Map = - mapOf("type" to "boolean", "description" to description) + fun boolean(description: String? = null): Map = property("boolean", description) /** * An argument holding an object whose keys are not known ahead of time. @@ -49,9 +48,11 @@ object ToolSchema { * A backend whose provider cannot declare one (Gemini rejects an object with no properties) * degrades it to JSON text, so a handler reading such an argument must accept either shape. * - * @param description what the argument means, as the model will read it. + * @param description what it means, as the model reads it; null for a built-in tool. * @return the property schema. */ - fun freeform(description: String): Map = - mapOf("type" to "object", "description" to description) + fun freeform(description: String? = null): Map = property("object", description) + + private fun property(type: String, description: String?): Map = + if (description == null) mapOf("type" to type) else mapOf("type" to type, "description" to description) } diff --git a/plugins/AI-Core/src/main/kotlin/com/itsaky/androidide/plugins/aicore/tool/handlers/AddDependencyHandler.kt b/plugins/AI-Core/src/main/kotlin/com/itsaky/androidide/plugins/aicore/tool/handlers/AddDependencyHandler.kt index 88a1dfcd..b706773c 100644 --- a/plugins/AI-Core/src/main/kotlin/com/itsaky/androidide/plugins/aicore/tool/handlers/AddDependencyHandler.kt +++ b/plugins/AI-Core/src/main/kotlin/com/itsaky/androidide/plugins/aicore/tool/handlers/AddDependencyHandler.kt @@ -21,15 +21,10 @@ class AddDependencyHandler( ) : ToolHandler { override val toolName = "add_dependency" override val parametersSchema = ToolSchema.objectOf( - "dependency" to ToolSchema.string( - "Maven coordinate to add, as group:artifact:version." - ), - "build_file" to ToolSchema.string( - "Project-relative build file to add it to. Defaults to the app module's." - ), + "dependency" to ToolSchema.string(), + "build_file" to ToolSchema.string(), required = listOf("dependency"), ) - override val description = "Add a Maven dependency to the project build file" override val requiresApproval = true override val mutatesProject = true @@ -42,9 +37,13 @@ class AddDependencyHandler( return ToolResult.failure("dependency is required (e.g., 'com.squareup.retrofit2:retrofit:2.9.0')") } - val buildFile = args["build_file"]?.toString()?.trim() ?: DEFAULT_BUILD_FILE + val buildFile = args["build_file"]?.toString()?.trim()?.takeIf { it.isNotEmpty() } + ?: DEFAULT_BUILD_FILE + // The service opens the path as given, so a project-relative one must be made absolute. + val buildFilePath = PathGuard.resolveWithin(buildFile)?.absolutePath + ?: return ToolResult.failure("Build file path must be within project directory") - Log.d(TAG, "Adding dependency: $dependency to $buildFile") + Log.d(TAG, "Adding dependency: $dependency to $buildFilePath") return try { val service = pluginContext.services.get(IdeProjectManipulationService::class.java) @@ -53,7 +52,7 @@ class AddDependencyHandler( return ToolResult.failure("Project manipulation service not available") } - val success = service.addDependency(dependency, buildFile) + val success = service.addDependency(dependency, buildFilePath) if (success) { Log.d(TAG, "Dependency added successfully: $dependency") ToolResult.success( diff --git a/plugins/AI-Core/src/main/kotlin/com/itsaky/androidide/plugins/aicore/tool/handlers/BuiltInToolHandlers.kt b/plugins/AI-Core/src/main/kotlin/com/itsaky/androidide/plugins/aicore/tool/handlers/BuiltInToolHandlers.kt index 31d2270e..13bd6ff8 100644 --- a/plugins/AI-Core/src/main/kotlin/com/itsaky/androidide/plugins/aicore/tool/handlers/BuiltInToolHandlers.kt +++ b/plugins/AI-Core/src/main/kotlin/com/itsaky/androidide/plugins/aicore/tool/handlers/BuiltInToolHandlers.kt @@ -1,6 +1,7 @@ package com.itsaky.androidide.plugins.aicore.tool.handlers import com.itsaky.androidide.plugins.PluginContext +import com.itsaky.androidide.plugins.aicore.models.ToolResult import com.itsaky.androidide.plugins.aicore.tool.ToolHandler /** @@ -11,11 +12,15 @@ import com.itsaky.androidide.plugins.aicore.tool.ToolHandler object BuiltInToolHandlers { /** - * Builds one handler per built-in tool. + * Builds one handler per built-in tool, the web tools included. * @param context the plugin context each handler works through. + * @param webSearch runs one web search through the active backend. * @return the handlers, read-only tools first. */ - fun create(context: PluginContext): List = listOf( + fun create( + context: PluginContext, + webSearch: suspend (String) -> ToolResult = { ToolResult.failure("Web search is unavailable") }, + ): List = listOf( // Read-only tools ReadFileHandler(context), ListFilesHandler(context), @@ -32,5 +37,8 @@ object BuiltInToolHandlers { GradleSyncHandler(context), // Template tool GenerateFromTemplateHandler(context), + // Web tools + WebSearchHandler(webSearch), + FetchUrlHandler(), ) } diff --git a/plugins/AI-Core/src/main/kotlin/com/itsaky/androidide/plugins/aicore/tool/handlers/CreateFileHandler.kt b/plugins/AI-Core/src/main/kotlin/com/itsaky/androidide/plugins/aicore/tool/handlers/CreateFileHandler.kt index eb529eba..4928dd81 100644 --- a/plugins/AI-Core/src/main/kotlin/com/itsaky/androidide/plugins/aicore/tool/handlers/CreateFileHandler.kt +++ b/plugins/AI-Core/src/main/kotlin/com/itsaky/androidide/plugins/aicore/tool/handlers/CreateFileHandler.kt @@ -17,11 +17,10 @@ class CreateFileHandler( ) : ToolHandler { override val toolName = "create_file" override val parametersSchema = ToolSchema.objectOf( - "file_path" to ToolSchema.string("Project-relative path of the file to create."), - "content" to ToolSchema.string("The file's full contents."), + "file_path" to ToolSchema.string(), + "content" to ToolSchema.string(), required = listOf("file_path", "content"), ) - override val description = "Create a new file with given content" override val requiresApproval = true // Requires approval for file creation override val mutatesProject = true override val pathArgs = listOf("file_path") diff --git a/plugins/AI-Core/src/main/kotlin/com/itsaky/androidide/plugins/aicore/tool/handlers/EditFileHandler.kt b/plugins/AI-Core/src/main/kotlin/com/itsaky/androidide/plugins/aicore/tool/handlers/EditFileHandler.kt index 7677f6bc..55fde688 100644 --- a/plugins/AI-Core/src/main/kotlin/com/itsaky/androidide/plugins/aicore/tool/handlers/EditFileHandler.kt +++ b/plugins/AI-Core/src/main/kotlin/com/itsaky/androidide/plugins/aicore/tool/handlers/EditFileHandler.kt @@ -38,21 +38,12 @@ class EditFileHandler( override val toolName = TOOL_NAME override val parametersSchema = ToolSchema.objectOf( - ARG_PATH to ToolSchema.string("Project-relative path of the file to edit."), - ARG_OLD to ToolSchema.string( - "The exact text to find, copied byte-for-byte from the file including indentation." - ), - ARG_NEW to ToolSchema.string("What to put in its place; empty deletes it."), - ARG_REPLACE_ALL to ToolSchema.boolean( - "Replace every occurrence. When false the text must match exactly once." - ), + ARG_PATH to ToolSchema.string(), + ARG_OLD to ToolSchema.string(), + ARG_NEW to ToolSchema.string(), + ARG_REPLACE_ALL to ToolSchema.boolean(), required = listOf(ARG_PATH, ARG_OLD, ARG_NEW), ) - override val description = - "Edit an existing file by replacing an exact snippet: give file_path, old_string " + - "(text to find, copied exactly including indentation) and new_string (its " + - "replacement; empty deletes it). old_string must match exactly once unless " + - "replace_all is true. Prefer this over update_file for changing a file." override val requiresApproval = true override val mutatesProject = true override val pathArgs = listOf(ARG_PATH) diff --git a/plugins/AI-Core/src/main/kotlin/com/itsaky/androidide/plugins/aicore/tool/handlers/FetchUrlHandler.kt b/plugins/AI-Core/src/main/kotlin/com/itsaky/androidide/plugins/aicore/tool/handlers/FetchUrlHandler.kt new file mode 100644 index 00000000..03a759d4 --- /dev/null +++ b/plugins/AI-Core/src/main/kotlin/com/itsaky/androidide/plugins/aicore/tool/handlers/FetchUrlHandler.kt @@ -0,0 +1,160 @@ +package com.itsaky.androidide.plugins.aicore.tool.handlers + +import android.net.TrafficStats +import android.util.Log +import com.itsaky.androidide.plugins.aicore.logging.LOG_PREFIX +import com.itsaky.androidide.plugins.aicore.models.ToolResult +import com.itsaky.androidide.plugins.aicore.tool.ToolHandler +import com.itsaky.androidide.plugins.aicore.tool.ToolSchema +import com.itsaky.androidide.plugins.aicore.tool.Validation +import com.itsaky.androidide.plugins.aicore.tool.web.WebAccess +import com.itsaky.androidide.plugins.aicore.tool.web.WebPageText +import java.io.ByteArrayOutputStream +import java.io.IOException +import java.net.HttpURLConnection +import java.net.URI +import java.net.URL +import java.nio.charset.Charset +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext + +private const val TAG = "$LOG_PREFIX.FetchUrlHandler" + +/** + * Reads one web page or file over HTTP(S) and hands back its text, for a URL the user linked or a + * page a search turned up. Asks first: the model picks the host, and a page it read can ask it to + * visit another, so the dialog is where the user sees where the request is going. + */ +class FetchUrlHandler : ToolHandler { + + override val toolName = WebAccess.FETCH_URL_TOOL + override val parametersSchema = ToolSchema.objectOf( + "url" to ToolSchema.string(), + required = listOf("url"), + ) + override val requiresApproval = true + override val argAliases = mapOf("link" to "url", "address" to "url") + + override suspend fun validate(args: Map): Validation { + val url = args["url"]?.toString()?.trim().orEmpty() + val problem = problemWith(url) + ?: return Validation.Accepted(args + ("url" to WebPageText.preferredUrl(url))) + return Validation.Rejected(ToolResult.failure(problem)) + } + + override suspend fun execute(args: Map): ToolResult { + val url = args["url"]?.toString()?.trim().orEmpty() + problemWith(url)?.let { return ToolResult.failure(it) } + return try { + withContext(Dispatchers.IO) { fetch(url) } + } catch (e: IOException) { + Log.w(TAG, "fetch failed: $url", e) + ToolResult.failure("Could not fetch $url: ${e.message ?: e.javaClass.simpleName}") + } + } + + /** + * GETs [url], following redirects by hand so each hop is held to [problemWith] too: the + * platform follows only same-scheme redirects, and checks none of them. + */ + private fun fetch(url: String): ToolResult { + var current = url + repeat(MAX_REDIRECTS + 1) { + val conn = open(current) + try { + val code = conn.responseCode + if (code in 300..399) { + val location = conn.getHeaderField("Location") + ?: return ToolResult.failure("$current redirected without saying where") + current = URL(URL(current), location).toString() + problemWith(current)?.let { return ToolResult.failure("Redirected to $current: $it") } + return@repeat + } + if (code !in 200..299) { + return ToolResult.failure("$current answered HTTP $code") + } + return read(current, conn) + } finally { + conn.disconnect() + } + } + return ToolResult.failure("$url redirected more than $MAX_REDIRECTS times") + } + + private fun open(url: String): HttpURLConnection { + // Tagged, or the host's debug StrictMode flags an untagged socket from plugin code. + val previous = TrafficStats.getThreadStatsTag() + TrafficStats.setThreadStatsTag(TRAFFIC_TAG) + try { + return (URL(url).openConnection() as HttpURLConnection).apply { + instanceFollowRedirects = false + connectTimeout = TIMEOUT_MS + readTimeout = TIMEOUT_MS + setRequestProperty("User-Agent", USER_AGENT) + setRequestProperty("Accept", "text/html,text/plain,application/json,*/*;q=0.5") + connect() + } + } finally { + TrafficStats.setThreadStatsTag(previous) + } + } + + private fun read(url: String, conn: HttpURLConnection): ToolResult { + val contentType = conn.contentType + if (!WebPageText.isReadable(contentType)) { + return ToolResult.failure("$url is not a text page ($contentType)") + } + val (bytes, cut) = conn.inputStream.use { stream -> + val out = ByteArrayOutputStream() + val buffer = ByteArray(8 * 1024) + var cut = false + while (true) { + val n = stream.read(buffer) + if (n < 0) break + if (out.size() + n > MAX_BYTES) { + out.write(buffer, 0, MAX_BYTES - out.size()) + cut = true + break + } + out.write(buffer, 0, n) + } + out.toByteArray() to cut + } + val raw = String(bytes, charsetOf(contentType)) + val text = if (WebPageText.isHtml(contentType, raw)) WebPageText.htmlToText(raw) else raw.trim() + if (text.isBlank()) return ToolResult.failure("$url returned no readable text") + val note = if (cut) " (first ${MAX_BYTES / 1024} KB only)" else "" + return ToolResult.success("Fetched ${text.length} characters from $url$note", text) + } + + private fun charsetOf(contentType: String?): Charset { + val name = contentType?.split(';') + ?.map { it.trim() } + ?.firstOrNull { it.startsWith("charset=", ignoreCase = true) } + ?.substringAfter('=')?.trim('"', ' ') + return runCatching { name?.let(Charset::forName) }.getOrNull() ?: Charsets.UTF_8 + } + + private companion object { + const val MAX_REDIRECTS = 5 + const val MAX_BYTES = 2 * 1024 * 1024 + const val TIMEOUT_MS = 20_000 + const val USER_AGENT = "CodeOnTheGo-Agent/1.0 (+https://github.com/appdevforall/CodeOnTheGo)" + + /** `"ACWF"` in ASCII, so a raw `dumpsys netstats` shows these as the agent's web fetches. */ + const val TRAFFIC_TAG = 0x41435746 + + /** + * Why [url] cannot be fetched, or null when it can: only absolute http(s) URLs with a host, + * so a model cannot turn this into a way to read `file://` or `content://` paths. + */ + fun problemWith(url: String): String? { + if (url.isEmpty()) return "url is required" + val uri = runCatching { URI(url) }.getOrNull() ?: return "Not a valid URL: $url" + val scheme = uri.scheme?.lowercase() + if (scheme != "http" && scheme != "https") return "Only http and https URLs can be fetched: $url" + if (uri.host.isNullOrEmpty()) return "URL has no host: $url" + return null + } + } +} diff --git a/plugins/AI-Core/src/main/kotlin/com/itsaky/androidide/plugins/aicore/tool/handlers/GenerateFromTemplateHandler.kt b/plugins/AI-Core/src/main/kotlin/com/itsaky/androidide/plugins/aicore/tool/handlers/GenerateFromTemplateHandler.kt index 6b43459d..24bbdbc9 100644 --- a/plugins/AI-Core/src/main/kotlin/com/itsaky/androidide/plugins/aicore/tool/handlers/GenerateFromTemplateHandler.kt +++ b/plugins/AI-Core/src/main/kotlin/com/itsaky/androidide/plugins/aicore/tool/handlers/GenerateFromTemplateHandler.kt @@ -19,13 +19,10 @@ class GenerateFromTemplateHandler( ) : ToolHandler { override val toolName = "generate_from_template" override val parametersSchema = ToolSchema.objectOf( - "template_name" to ToolSchema.string("Name of the registered template to generate from."), - "variables" to ToolSchema.freeform( - "Template variables, as a flat object of name to value." - ), + "template_name" to ToolSchema.string(), + "variables" to ToolSchema.freeform(), required = listOf("template_name"), ) - override val description = "Generate files from Pebble templates with variable substitution" /** * A tool that generates files into the project asks first. Declared true although [execute] diff --git a/plugins/AI-Core/src/main/kotlin/com/itsaky/androidide/plugins/aicore/tool/handlers/GradleSyncHandler.kt b/plugins/AI-Core/src/main/kotlin/com/itsaky/androidide/plugins/aicore/tool/handlers/GradleSyncHandler.kt index a408f700..4c07d4a6 100644 --- a/plugins/AI-Core/src/main/kotlin/com/itsaky/androidide/plugins/aicore/tool/handlers/GradleSyncHandler.kt +++ b/plugins/AI-Core/src/main/kotlin/com/itsaky/androidide/plugins/aicore/tool/handlers/GradleSyncHandler.kt @@ -19,7 +19,6 @@ class GradleSyncHandler( private val pluginContext: PluginContext ) : ToolHandler { override val toolName = "gradle_sync" - override val description = "Sync the Gradle project (reload dependencies and rebuild cache)" /** * Approved like the build tool it is, not like a read: a sync starts a real Gradle build, can diff --git a/plugins/AI-Core/src/main/kotlin/com/itsaky/androidide/plugins/aicore/tool/handlers/ListFilesHandler.kt b/plugins/AI-Core/src/main/kotlin/com/itsaky/androidide/plugins/aicore/tool/handlers/ListFilesHandler.kt index 17d3933d..d29bd6d1 100644 --- a/plugins/AI-Core/src/main/kotlin/com/itsaky/androidide/plugins/aicore/tool/handlers/ListFilesHandler.kt +++ b/plugins/AI-Core/src/main/kotlin/com/itsaky/androidide/plugins/aicore/tool/handlers/ListFilesHandler.kt @@ -18,11 +18,8 @@ class ListFilesHandler( ) : ToolHandler { override val toolName = "list_files" override val parametersSchema = ToolSchema.objectOf( - "directory" to ToolSchema.string( - "Project-relative directory to list. Empty or omitted lists the project root." - ), + "directory" to ToolSchema.string(), ) - 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 diff --git a/plugins/AI-Core/src/main/kotlin/com/itsaky/androidide/plugins/aicore/tool/handlers/OpenFileHandler.kt b/plugins/AI-Core/src/main/kotlin/com/itsaky/androidide/plugins/aicore/tool/handlers/OpenFileHandler.kt index efa5276d..87a20823 100644 --- a/plugins/AI-Core/src/main/kotlin/com/itsaky/androidide/plugins/aicore/tool/handlers/OpenFileHandler.kt +++ b/plugins/AI-Core/src/main/kotlin/com/itsaky/androidide/plugins/aicore/tool/handlers/OpenFileHandler.kt @@ -24,10 +24,9 @@ class OpenFileHandler( ) : ToolHandler { override val toolName = "open_file" override val parametersSchema = ToolSchema.objectOf( - "file_path" to ToolSchema.string("Project-relative path of the file to open."), + "file_path" to ToolSchema.string(), required = listOf("file_path"), ) - override val description = "Open a file in the IDE editor" override val requiresApproval = false override val pathArgs = listOf("file_path") override val resolvesPathsInternally = true diff --git a/plugins/AI-Core/src/main/kotlin/com/itsaky/androidide/plugins/aicore/tool/handlers/ReadBuildOutputHandler.kt b/plugins/AI-Core/src/main/kotlin/com/itsaky/androidide/plugins/aicore/tool/handlers/ReadBuildOutputHandler.kt index 331a0c9c..efec6fcf 100644 --- a/plugins/AI-Core/src/main/kotlin/com/itsaky/androidide/plugins/aicore/tool/handlers/ReadBuildOutputHandler.kt +++ b/plugins/AI-Core/src/main/kotlin/com/itsaky/androidide/plugins/aicore/tool/handlers/ReadBuildOutputHandler.kt @@ -20,7 +20,6 @@ class ReadBuildOutputHandler( private val pluginContext: PluginContext ) : ToolHandler { override val toolName = "read_build_output" - override val description = "Read the current build output and status" override val requiresApproval = false override suspend fun execute(args: Map): ToolResult { diff --git a/plugins/AI-Core/src/main/kotlin/com/itsaky/androidide/plugins/aicore/tool/handlers/ReadFileHandler.kt b/plugins/AI-Core/src/main/kotlin/com/itsaky/androidide/plugins/aicore/tool/handlers/ReadFileHandler.kt index fbc235df..69802816 100644 --- a/plugins/AI-Core/src/main/kotlin/com/itsaky/androidide/plugins/aicore/tool/handlers/ReadFileHandler.kt +++ b/plugins/AI-Core/src/main/kotlin/com/itsaky/androidide/plugins/aicore/tool/handlers/ReadFileHandler.kt @@ -17,10 +17,9 @@ class ReadFileHandler( ) : ToolHandler { override val toolName = "read_file" override val parametersSchema = ToolSchema.objectOf( - "file_path" to ToolSchema.string("Project-relative path of the file to read."), + "file_path" to ToolSchema.string(), required = listOf("file_path"), ) - override val description = "Read the contents of a file" override val requiresApproval = false override val pathArgs = listOf("file_path", "path") override val resolvesPathsInternally = true diff --git a/plugins/AI-Core/src/main/kotlin/com/itsaky/androidide/plugins/aicore/tool/handlers/RunAppHandler.kt b/plugins/AI-Core/src/main/kotlin/com/itsaky/androidide/plugins/aicore/tool/handlers/RunAppHandler.kt index bb10dc8d..8237a186 100644 --- a/plugins/AI-Core/src/main/kotlin/com/itsaky/androidide/plugins/aicore/tool/handlers/RunAppHandler.kt +++ b/plugins/AI-Core/src/main/kotlin/com/itsaky/androidide/plugins/aicore/tool/handlers/RunAppHandler.kt @@ -32,12 +32,6 @@ class RunAppHandler( ) : ToolHandler { override val toolName = "run_app" - // The install is gated on a system prompt only the user can answer, so the model is told the - // success it gets back is weaker than "the app is running" — otherwise it reports the launch. - override val description = "Build the app and install it on this device. The user has to " + - "confirm a system install prompt, so success means the install started, not that the " + - "app is on screen" - // Build operation requires approval for safety override val requiresApproval = true diff --git a/plugins/AI-Core/src/main/kotlin/com/itsaky/androidide/plugins/aicore/tool/handlers/SearchProjectHandler.kt b/plugins/AI-Core/src/main/kotlin/com/itsaky/androidide/plugins/aicore/tool/handlers/SearchProjectHandler.kt index aa138f38..e4bb1a95 100644 --- a/plugins/AI-Core/src/main/kotlin/com/itsaky/androidide/plugins/aicore/tool/handlers/SearchProjectHandler.kt +++ b/plugins/AI-Core/src/main/kotlin/com/itsaky/androidide/plugins/aicore/tool/handlers/SearchProjectHandler.kt @@ -18,16 +18,11 @@ class SearchProjectHandler( ) : ToolHandler { override val toolName = "search_project" override val parametersSchema = ToolSchema.objectOf( - "query" to ToolSchema.string("Text to search for; a file name unless searching contents."), - "project_dir" to ToolSchema.string( - "Project-relative directory to search under. Defaults to the whole project." - ), - "search_in_contents" to ToolSchema.boolean( - "Search inside files instead of matching their names." - ), + "query" to ToolSchema.string(), + "project_dir" to ToolSchema.string(), + "search_in_contents" to ToolSchema.boolean(), required = listOf("query"), ) - override val description = "Search for files by name or content in the project" override val requiresApproval = false override val pathArgs = listOf("project_dir") diff --git a/plugins/AI-Core/src/main/kotlin/com/itsaky/androidide/plugins/aicore/tool/handlers/UpdateFileHandler.kt b/plugins/AI-Core/src/main/kotlin/com/itsaky/androidide/plugins/aicore/tool/handlers/UpdateFileHandler.kt index 975a32da..d93352cb 100644 --- a/plugins/AI-Core/src/main/kotlin/com/itsaky/androidide/plugins/aicore/tool/handlers/UpdateFileHandler.kt +++ b/plugins/AI-Core/src/main/kotlin/com/itsaky/androidide/plugins/aicore/tool/handlers/UpdateFileHandler.kt @@ -17,11 +17,10 @@ class UpdateFileHandler( ) : ToolHandler { override val toolName = "update_file" override val parametersSchema = ToolSchema.objectOf( - "file_path" to ToolSchema.string("Project-relative path of the file to overwrite."), - "content" to ToolSchema.string("The file's new full contents."), + "file_path" to ToolSchema.string(), + "content" to ToolSchema.string(), required = listOf("file_path", "content"), ) - override val description = "Update an existing file with new content" override val requiresApproval = true // Requires approval for file modification override val mutatesProject = true override val pathArgs = listOf("file_path") diff --git a/plugins/AI-Core/src/main/kotlin/com/itsaky/androidide/plugins/aicore/tool/handlers/WebSearchHandler.kt b/plugins/AI-Core/src/main/kotlin/com/itsaky/androidide/plugins/aicore/tool/handlers/WebSearchHandler.kt new file mode 100644 index 00000000..d87f273b --- /dev/null +++ b/plugins/AI-Core/src/main/kotlin/com/itsaky/androidide/plugins/aicore/tool/handlers/WebSearchHandler.kt @@ -0,0 +1,42 @@ +package com.itsaky.androidide.plugins.aicore.tool.handlers + +import android.util.Log +import com.itsaky.androidide.plugins.aicore.logging.LOG_PREFIX +import com.itsaky.androidide.plugins.aicore.models.ToolResult +import com.itsaky.androidide.plugins.aicore.tool.ToolHandler +import com.itsaky.androidide.plugins.aicore.tool.ToolSchema +import com.itsaky.androidide.plugins.aicore.tool.web.WebAccess +import kotlinx.coroutines.CancellationException + +private const val TAG = "$LOG_PREFIX.WebSearchHandler" + +/** + * Searches the web through the active backend. Needs no approval: the query goes to the provider + * already receiving the whole conversation. + * + * @param search runs one search; see [com.itsaky.androidide.plugins.aicore.tool.web.BackendWebSearch]. + */ +class WebSearchHandler( + private val search: suspend (String) -> ToolResult, +) : ToolHandler { + + override val toolName = WebAccess.WEB_SEARCH_TOOL + override val parametersSchema = ToolSchema.objectOf( + "query" to ToolSchema.string(), + required = listOf("query"), + ) + override val argAliases = mapOf("q" to "query", "search" to "query", "text" to "query") + + override suspend fun execute(args: Map): ToolResult { + val query = args["query"]?.toString()?.trim() + if (query.isNullOrEmpty()) return ToolResult.failure("query is required") + return try { + search(query) + } catch (ce: CancellationException) { + throw ce + } catch (e: Exception) { + Log.w(TAG, "search failed", e) + ToolResult.failure("Web search failed: ${e.message ?: e.javaClass.simpleName}") + } + } +} diff --git a/plugins/AI-Core/src/main/kotlin/com/itsaky/androidide/plugins/aicore/tool/web/BackendWebSearch.kt b/plugins/AI-Core/src/main/kotlin/com/itsaky/androidide/plugins/aicore/tool/web/BackendWebSearch.kt new file mode 100644 index 00000000..ff1d3e3b --- /dev/null +++ b/plugins/AI-Core/src/main/kotlin/com/itsaky/androidide/plugins/aicore/tool/web/BackendWebSearch.kt @@ -0,0 +1,93 @@ +package com.itsaky.androidide.plugins.aicore.tool.web + +import com.itsaky.androidide.plugins.ai.prompt.PromptConfigProvider +import com.itsaky.androidide.plugins.ai.prompt.PromptTemplateEngine +import com.itsaky.androidide.plugins.aicore.models.ToolResult +import com.itsaky.androidide.plugins.aicore.prompt.PromptVariables +import com.itsaky.androidide.plugins.aicore.prompt.SessionContext +import com.itsaky.androidide.plugins.aicore.prompt.config.AgentPromptConfig +import com.itsaky.androidide.plugins.services.LlmInferenceService.LlmBackend +import com.itsaky.androidide.plugins.services.LlmInferenceService.LlmConfig +import kotlinx.coroutines.future.await + +/** + * Answers a web search through the active backend's own provider search, as a separate one-off + * request with no other tools declared: Gemini 2.x refuses Google Search beside function calling, + * and OpenAI offers web search only on its Responses API, not the chat transport the agent uses. + * + * @param config supplies the prompt config holding `web_search.instruction`. + * @param backendId the backend the current run is against. + * @param backend resolves that backend, or null when it cannot be reached. + * @param currentTime the device's date and time as the prompt words it, so "latest" means today. + */ +class BackendWebSearch( + private val config: PromptConfigProvider, + private val backendId: () -> String, + private val backend: () -> LlmBackend?, + private val currentTime: () -> String = { SessionContext.current().currentTime }, +) { + + /** + * Searches for [query]. + * + * @param query what to look up, as the model phrased it. + * @return the backend's findings with their sources, or why it could not search. + */ + suspend fun search(query: String): ToolResult { + val backend = backend() + ?: return ToolResult.failure("No AI backend is available to search with") + val request = LlmConfig(backendId()).apply { + temperature = SEARCH_TEMPERATURE + maxTokens = SEARCH_MAX_TOKENS + systemPrompt = instruction(config.config(), currentTime()) + // A backend that cannot search must refuse on seeing this, not answer from memory. + extraParams = mapOf(WebAccess.EXTRA_PARAM_WEB_SEARCH to true) + } + val response = backend.generate(query, request).await() + if (!response.success) { + return ToolResult.failure(response.error?.takeIf { it.isNotBlank() } ?: "Web search failed") + } + val text = response.text?.trim().orEmpty() + if (text.isEmpty()) return ToolResult.failure("Web search returned nothing for: $query") + return ToolResult.success("Searched the web for: $query", text) + } + + companion object { + + /** + * Renders the search request's system prompt. + * + * @param config the loaded prompt config. + * @param currentTime the device's date and time, as the prompt words it. + * @return the instruction. + */ + fun instruction(config: AgentPromptConfig, currentTime: String): String = + PromptTemplateEngine.render( + config.webSearch.instruction, + mapOf(PromptVariables.CURRENT_TIME to currentTime), + ) + + /** + * Renders the instruction once, to catch a name typo on activation. + * + * @param config the loaded prompt config. + * @return the failure's message; empty when it renders. + */ + fun problems(config: AgentPromptConfig): List = try { + instruction(config, CHECK_TIME) + emptyList() + } catch (e: IllegalArgumentException) { + listOfNotNull(e.message) + } + + private const val CHECK_TIME = "Monday, 1 January 2029, 09:00 (UTC, UTC+00:00)" + + /** Low: this is a report of what the results say, not a place for invention. */ + private const val SEARCH_TEMPERATURE = 0.2f + /** + * Gemini counts its thinking against this cap, and 2048 cut a report mid-sentence before + * the version it was asked for (ADFA-6223). OpenAI's search request sends no cap at all. + */ + private const val SEARCH_MAX_TOKENS = 8192 + } +} diff --git a/plugins/AI-Core/src/main/kotlin/com/itsaky/androidide/plugins/aicore/tool/web/VerificationPolicy.kt b/plugins/AI-Core/src/main/kotlin/com/itsaky/androidide/plugins/aicore/tool/web/VerificationPolicy.kt new file mode 100644 index 00000000..d15e27da --- /dev/null +++ b/plugins/AI-Core/src/main/kotlin/com/itsaky/androidide/plugins/aicore/tool/web/VerificationPolicy.kt @@ -0,0 +1,88 @@ +package com.itsaky.androidide.plugins.aicore.tool.web + +import com.itsaky.androidide.plugins.services.LlmInferenceService.LlmConfig + +/** + * Decides, from the message rather than the model's confidence, when a run must search before it + * answers: left to itself the model approved Ktor's removed `JsonFeature` as current (ADFA-6223). + */ +object VerificationPolicy { + + /** + * @param message what the user typed, without the attached files. + * @param hasAttachedFiles whether files were attached, which gives a review request code to judge. + * @return whether the run's first turn must call [WebAccess.WEB_SEARCH_TOOL]. + */ + fun requiresWebCheck(message: String, hasAttachedFiles: Boolean = false): Boolean = + containsCode(message) || + asksWhetherCurrent(message) || + (hasAttachedFiles && asksForReview(message)) + + /** + * [config] with [tool] required, for the run's first turn only: forced on every turn, the + * model could never answer. The copy leaves [config] as it was for the turns after. + * + * @param config the run's config. + * @param tool the tool the model must call. + * @return a new config, identical but for [WebAccess.EXTRA_PARAM_REQUIRED_TOOL]. + */ + fun requiring(config: LlmConfig, tool: String): LlmConfig = LlmConfig(config.backendId).apply { + modelName = config.modelName + temperature = config.temperature + maxTokens = config.maxTokens + stopSequences = config.stopSequences + systemPrompt = config.systemPrompt + extraParams = config.extraParams.orEmpty() + (WebAccess.EXTRA_PARAM_REQUIRED_TOOL to tool) + } + + /** A fenced block, or at least [MIN_CODE_LINES] lines that read as source or build script. */ + internal fun containsCode(message: String): Boolean { + if (message.contains(FENCE)) return true + return message.lineSequence().count { CODE_LINE.containsMatchIn(it) } >= MIN_CODE_LINES + } + + /** Asks whether an API, library or practice is deprecated, outdated or the latest one. */ + internal fun asksWhetherCurrent(message: String): Boolean = CURRENCY_WORDS.containsMatchIn(message) + + /** Asks for code to be reviewed, audited or analyzed. */ + internal fun asksForReview(message: String): Boolean = REVIEW_WORDS.containsMatchIn(message) + + private const val FENCE = "```" + + /** Two, so a sentence that happens to mention `foo.bar()` does not count as a snippet. */ + private const val MIN_CODE_LINES = 2 + + private val CODE_LINE = Regex( + """^\s*(""" + + """(import|package)\s+[\w.]+""" + + """|((private|internal|public|protected|override|suspend|inline|open|abstract)\s+)*fun\s+\w""" + + """|(val|var)\s+\w+\s*[:=]""" + + """|(data\s+|sealed\s+|enum\s+)?(class|interface|object)\s+\w+""" + + """|@\w+""" + + """|(implementation|api|kapt|ksp|testImplementation)\s*[("]""" + + """|install\(""" + + """|[\w.]+\([^)]*\)\s*[{;]?\s*$""" + + """|[{}]\s*$""" + + """)""", + ) + + // Not \b and no (?U): Android compiles with ICU, which refuses (?U), and a JVM \b is ASCII-only. + private const val START = """(? = setOf(WEB_SEARCH_TOOL, FETCH_URL_TOOL) +} diff --git a/plugins/AI-Core/src/main/kotlin/com/itsaky/androidide/plugins/aicore/tool/web/WebPageText.kt b/plugins/AI-Core/src/main/kotlin/com/itsaky/androidide/plugins/aicore/tool/web/WebPageText.kt new file mode 100644 index 00000000..90728ca2 --- /dev/null +++ b/plugins/AI-Core/src/main/kotlin/com/itsaky/androidide/plugins/aicore/tool/web/WebPageText.kt @@ -0,0 +1,103 @@ +package com.itsaky.androidide.plugins.aicore.tool.web + +/** + * Turns what a URL returned into text a model can read, and a URL into the one worth fetching. + * Pure, so every rule is unit-testable without a network. + */ +object WebPageText { + + /** Elements whose content is never prose: dropped whole, content and all. */ + private val DROPPED_ELEMENTS = Regex( + """<(script|style|noscript|svg|template|iframe)\b[^>]*>.*?""", + setOf(RegexOption.IGNORE_CASE, RegexOption.DOT_MATCHES_ALL), + ) + private val COMMENT = Regex("""""", RegexOption.DOT_MATCHES_ALL) + private val TITLE = Regex("""]*>(.*?)""", setOf(RegexOption.IGNORE_CASE, RegexOption.DOT_MATCHES_ALL)) + private val HEAD = Regex("""]*>.*?""", setOf(RegexOption.IGNORE_CASE, RegexOption.DOT_MATCHES_ALL)) + // An item opens its own line, so its closing tag adds none; that would leave a blank line between items. + private val LIST_ITEM = Regex("""]*>""", RegexOption.IGNORE_CASE) + private val LINE_BREAK = Regex( + """|]*>""", + RegexOption.IGNORE_CASE, + ) + private val TAG = Regex("""<[^>]+>""") + private val NUMERIC_ENTITY = Regex("""&#(x[0-9a-fA-F]+|\d+);""") + private val NAMED_ENTITIES = mapOf( + " " to " ", "<" to "<", ">" to ">", """ to "\"", "'" to "'", + "'" to "'", "—" to "—", "–" to "–", "…" to "…", "©" to "©", + ) + private val SPACES = Regex("""[ \t\u000B\f\r]+""") + private val BLANK_LINES = Regex("""\n\s*\n\s*\n+""") + + /** `github.com/{owner}/{repo}/blob/{ref}/{path}`, whose page wraps the file in site chrome. */ + private val GITHUB_BLOB = Regex("""^https://github\.com/([^/]+)/([^/]+)/blob/(.+)$""") + + /** + * The URL to fetch in place of [url]: a GitHub file page becomes its raw file, since the page + * buries the contents under navigation the result cap would spend itself on. + * + * @param url the URL the model asked for. + * @return the URL to request. + */ + fun preferredUrl(url: String): String { + val match = GITHUB_BLOB.matchEntire(url) ?: return url + val (owner, repo, rest) = match.destructured + return "https://raw.githubusercontent.com/$owner/$repo/$rest" + } + + /** + * Whether a response of [contentType] is text this tool can hand back. + * + * @param contentType the `Content-Type` header, or null when the server sent none. + * @return true for text, HTML, JSON and XML; false for images, archives and the like. + */ + fun isReadable(contentType: String?): Boolean { + val type = contentType?.substringBefore(';')?.trim()?.lowercase() ?: return true + return type.isEmpty() || type.startsWith("text/") || type.endsWith("/json") || + type.endsWith("+json") || type.endsWith("/xml") || type.endsWith("+xml") || + type == "application/javascript" + } + + /** @return whether [contentType] or, lacking one, the body itself says HTML. */ + fun isHtml(contentType: String?, body: String): Boolean { + val type = contentType?.substringBefore(';')?.trim()?.lowercase() + if (!type.isNullOrEmpty()) return type == "text/html" || type == "application/xhtml+xml" + val start = body.trimStart().take(64).lowercase() + return start.startsWith(" + val code = match.groupValues[1] + val value = if (code.startsWith("x")) code.drop(1).toIntOrNull(16) else code.toIntOrNull() + value?.takeIf { Character.isValidCodePoint(it) }?.let { String(Character.toChars(it)) } ?: match.value + } + for ((entity, char) in NAMED_ENTITIES) decoded = decoded.replace(entity, char) + // Last, so "&lt;" decodes to the literal "<" it spelled rather than to "<". + return decoded.replace("&", "&") + } +} diff --git a/plugins/AI-Core/src/main/kotlin/com/itsaky/androidide/plugins/aicore/viewmodel/AgentActivity.kt b/plugins/AI-Core/src/main/kotlin/com/itsaky/androidide/plugins/aicore/viewmodel/AgentActivity.kt index c52127e2..49571d73 100644 --- a/plugins/AI-Core/src/main/kotlin/com/itsaky/androidide/plugins/aicore/viewmodel/AgentActivity.kt +++ b/plugins/AI-Core/src/main/kotlin/com/itsaky/androidide/plugins/aicore/viewmodel/AgentActivity.kt @@ -1,6 +1,8 @@ package com.itsaky.androidide.plugins.aicore.viewmodel +import com.itsaky.androidide.plugins.aicore.models.ToolResult import com.itsaky.androidide.plugins.aicore.tool.ToolCall +import com.itsaky.androidide.plugins.aicore.tool.web.WebAccess /** * What the one activity line says while a run works through its tool calls. @@ -14,6 +16,12 @@ object AgentActivity { /** Longest subject shown; past this the line wraps and stops being a line. */ const val SUBJECT_LIMIT = 40 + /** Longest argument value [logEntry] keeps; a file's whole content is not the point of the log. */ + const val LOG_ARG_LIMIT = 200 + + /** Longest web result [logEntry] keeps: a whole search report, Sources list included. */ + const val LOG_RESULT_LIMIT = 12000 + /** * Argument names that say what a call acts on, most specific first. A tool whose arguments * are all content (`gradle_sync`, `run_app`) matches none, and shows its name alone. @@ -48,6 +56,30 @@ object AgentActivity { return if (leaf.length <= SUBJECT_LIMIT) leaf else leaf.take(SUBJECT_LIMIT) + "…" } + /** + * One call and its result, as the exported chat records them: without it a transcript cannot + * tell a fact the search returned from one the model remembered (ADFA-6223). A web tool keeps + * its full result; a project tool only its message, so the export does not copy the project. + * + * @param call the call that ran. + * @param result what it returned. + * @return the entry, its first line naming the call and its arguments. + */ + fun logEntry(call: ToolCall, result: ToolResult): String = buildString { + append(if (result.success) "✓ " else "✗ ").append(call.name).append('(') + append(call.args.entries.joinToString(", ") { (key, value) -> "$key=${clip(value, LOG_ARG_LIMIT)}" }) + append(")\n").append(result.message) + val extra = if (result.success) result.data else result.error_details + if (call.name in WebAccess.TOOL_NAMES || !result.success) { + extra?.takeIf { it.isNotBlank() }?.let { append('\n').append(clip(it, LOG_RESULT_LIMIT)) } + } + } + + private fun clip(value: Any?, limit: Int): String { + val text = value?.toString().orEmpty() + return if (text.length <= limit) text else text.take(limit) + "…[${text.length - limit} more chars]" + } + /** * The tool names a run's summary lists: each one once, in the order the run first used it, so * a loop that read six files still reads as `read_file`. diff --git a/plugins/AI-Core/src/main/kotlin/com/itsaky/androidide/plugins/aicore/viewmodel/AgentRunReporter.kt b/plugins/AI-Core/src/main/kotlin/com/itsaky/androidide/plugins/aicore/viewmodel/AgentRunReporter.kt index 9896b3ee..a68c44af 100644 --- a/plugins/AI-Core/src/main/kotlin/com/itsaky/androidide/plugins/aicore/viewmodel/AgentRunReporter.kt +++ b/plugins/AI-Core/src/main/kotlin/com/itsaky/androidide/plugins/aicore/viewmodel/AgentRunReporter.kt @@ -77,6 +77,15 @@ internal class AgentRunReporter(private val notices: Notices) : AgentLoop.Events AgentTrace.refusal("LOOP", "turn=$turn", "stopped with a failed tool unaddressed") } + override suspend fun onUnfinishedReply(turn: Int) { + AgentTrace.stage("LOOP", "turn=$turn asked-to-finish=no-terminal-tool") + } + + // Reaching this means the backend did not force the call; see VerificationPolicy. + override suspend fun onRequiredToolSkipped(turn: Int, tool: String) { + AgentTrace.stage("VERIFY", "turn=$turn asked-for=$tool (answered without it)") + } + override suspend fun onMaxIterationsReached(turns: Int) { AgentTrace.refusal("LOOP", "turns=$turns", "iteration cap reached") notices.stepBudgetExhausted(turns) diff --git a/plugins/AI-Core/src/main/kotlin/com/itsaky/androidide/plugins/aicore/viewmodel/AnswerReview.kt b/plugins/AI-Core/src/main/kotlin/com/itsaky/androidide/plugins/aicore/viewmodel/AnswerReview.kt new file mode 100644 index 00000000..c49cae12 --- /dev/null +++ b/plugins/AI-Core/src/main/kotlin/com/itsaky/androidide/plugins/aicore/viewmodel/AnswerReview.kt @@ -0,0 +1,103 @@ +package com.itsaky.androidide.plugins.aicore.viewmodel + +import com.itsaky.androidide.plugins.ai.prompt.PromptTemplateEngine +import com.itsaky.androidide.plugins.aicore.prompt.PromptVariables +import com.itsaky.androidide.plugins.aicore.prompt.config.AgentPromptConfig + +/** + * Builds the second pass over an answer holding code, and reads what comes back. One pass writing + * a long answer broke the rules its own prompt stated (ADFA-6223); this holds the draft to them. + * The wording is `answer_review.yml`'s and `layout.answer_review`'s; this keeps limits and parsing. + */ +internal object AnswerReview { + + /** + * The line a complete reply ends on. The code checks for it rather than for a finish reason, + * which the service does not report: a reply without it was cut off, and the draft stands. + */ + const val END_MARKER = "<>" + + const val TEMPERATURE = 0.2f + + /** A review not back by then is abandoned; the draft stands. */ + const val TIMEOUT_MS = 180_000L + + /** Longest evidence sent; a run's web results can run past what is worth the tokens. */ + const val EVIDENCE_CHARS = 60_000 + + /** Fence that opens a code block; a reply holding one is what gets reviewed. */ + private const val FENCE = "```" + + private val THINKING = Regex("(?s).*?") + + /** @return whether [reply] holds code, which is what a review is for. */ + fun holdsCode(reply: String): Boolean = reply.contains(FENCE) + + /** + * @param config the loaded prompt config. + * @param currentTime the device's date and time, as the prompt words it. + * @return the request's system prompt. + */ + fun systemPrompt(config: AgentPromptConfig, currentTime: String): String = + PromptTemplateEngine.render( + config.answerReview.instruction, + mapOf(PromptVariables.CURRENT_TIME to currentTime, PromptVariables.END_MARKER to END_MARKER), + ) + + /** + * @param config the loaded prompt config. + * @param request what the user asked. + * @param evidence what the run's tools returned, one call per entry; empty when none ran. + * @param draft the answer to check. + * @return the request's user turn, each part inserted verbatim. + */ + fun prompt(config: AgentPromptConfig, request: String, evidence: String, draft: String): String { + val values = mapOf( + PromptVariables.ANSWER_REVIEW_NO_EVIDENCE to config.answerReview.noEvidence, + PromptVariables.REQUEST to request.trim(), + PromptVariables.EVIDENCE to evidence.trim().take(EVIDENCE_CHARS), + PromptVariables.HAS_EVIDENCE to evidence.isNotBlank(), + PromptVariables.DRAFT to draft.trim(), + ) + return PromptTemplateEngine.render(config.layout.answerReview, values) + } + + /** + * The corrected answer, or null to keep the draft: when the reply was cut off before + * [END_MARKER], came back empty, or changed nothing. + * + * @param raw the backend's reply text. + * @param draft the answer that was checked. + * @return the answer to show instead of [draft], or null. + */ + fun corrected(raw: String, draft: String): String? { + val text = raw.replace(THINKING, "") + val end = text.lastIndexOf(END_MARKER) + if (end < 0) return null + val answer = text.substring(0, end).trim() + if (answer.isEmpty() || answer == draft.trim()) return null + return answer + } + + /** + * Renders both texts, with and without evidence, to catch a name typo. + * + * @param config the loaded prompt config. + * @return one message per distinct failure; empty when every render succeeds. + */ + fun problems(config: AgentPromptConfig): List { + val renders: List<() -> String> = listOf( + { systemPrompt(config, "Monday, 1 January 2029, 09:00 (UTC, UTC+00:00)") }, + { prompt(config, "q", "evidence", "draft") }, + { prompt(config, "q", "", "draft") }, + ) + return renders.mapNotNull { check -> + try { + check() + null + } catch (e: IllegalArgumentException) { + e.message + } + }.distinct() + } +} diff --git a/plugins/AI-Core/src/main/kotlin/com/itsaky/androidide/plugins/aicore/viewmodel/ChatTitle.kt b/plugins/AI-Core/src/main/kotlin/com/itsaky/androidide/plugins/aicore/viewmodel/ChatTitle.kt index b5331b14..de8ca07d 100644 --- a/plugins/AI-Core/src/main/kotlin/com/itsaky/androidide/plugins/aicore/viewmodel/ChatTitle.kt +++ b/plugins/AI-Core/src/main/kotlin/com/itsaky/androidide/plugins/aicore/viewmodel/ChatTitle.kt @@ -1,10 +1,15 @@ package com.itsaky.androidide.plugins.aicore.viewmodel +import com.itsaky.androidide.plugins.ai.prompt.PromptTemplateEngine +import com.itsaky.androidide.plugins.aicore.prompt.PromptVariables +import com.itsaky.androidide.plugins.aicore.prompt.config.AgentPromptConfig + /** * Builds the request that asks the selected backend to name a chat, and cleans what comes back. + * The wording is `chat_title.yml`'s and `layout.chat_title`'s; this keeps the limits and parsing. * * Kept free of Android and of the service, so the parts that decide what the user sees as a title - * are unit-testable. The prompt is model-facing, so it stays here rather than in strings.xml. + * are unit-testable. */ internal object ChatTitle { @@ -27,27 +32,48 @@ internal object ChatTitle { /** One toolbar line; longer still ellipsizes there, but the sidebar row wraps nothing. */ const val MAX_CHARS = 60 - const val SYSTEM_PROMPT = - "You name chat conversations between a developer and a coding assistant. " + - "Reply with a short title of 2 to 6 words that says what the developer wants, " + - "in the developer's language. Plain text only: no quotes, no markdown, " + - "no trailing punctuation, no explanation." - private val THINKING = Regex("(?s).*?") private val LABEL = Regex("^(?i)title\\s*:\\s*") private val EDGE_MARKS = Regex("^[\\s#*_`\"'“”‘’>-]+|[\\s*_`\"'“”‘’.,;:!-]+$") private val WHITESPACE = Regex("\\s+") /** + * @param config the loaded prompt config. + * @return the request's system prompt. + */ + fun systemPrompt(config: AgentPromptConfig): String = + PromptTemplateEngine.render(config.chatTitle.instruction, emptyMap()) + + /** + * @param config the loaded prompt config. * @param userText the conversation's first user message. * @param replyText the agent's reply to it. - * @return the prompt, ending on `Title:` so a completion-style model answers with just that. + * @return the request's user turn, each side cut to [EXCERPT_CHARS] and inserted verbatim. + */ + fun prompt(config: AgentPromptConfig, userText: String, replyText: String): String { + val values = mapOf( + PromptVariables.USER_TEXT to userText.trim().take(EXCERPT_CHARS), + PromptVariables.REPLY_TEXT to replyText.trim().take(EXCERPT_CHARS), + ) + return PromptTemplateEngine.render(config.layout.chatTitle, values) + } + + /** + * Renders both texts, to catch a name typo. + * + * @param config the loaded prompt config. + * @return one message per distinct failure; empty when both render. */ - fun prompt(userText: String, replyText: String): String = buildString { - append("Conversation:\n") - append("Developer: ").append(userText.trim().take(EXCERPT_CHARS)).append('\n') - append("Assistant: ").append(replyText.trim().take(EXCERPT_CHARS)).append("\n\n") - append("Title:") + fun problems(config: AgentPromptConfig): List { + val renders: List<() -> String> = listOf({ systemPrompt(config) }, { prompt(config, "q", "a") }) + return renders.mapNotNull { check -> + try { + check() + null + } catch (e: IllegalArgumentException) { + e.message + } + }.distinct() } /** diff --git a/plugins/AI-Core/src/main/kotlin/com/itsaky/androidide/plugins/aicore/viewmodel/ChatViewModel.kt b/plugins/AI-Core/src/main/kotlin/com/itsaky/androidide/plugins/aicore/viewmodel/ChatViewModel.kt index f1ae032e..99c09c14 100644 --- a/plugins/AI-Core/src/main/kotlin/com/itsaky/androidide/plugins/aicore/viewmodel/ChatViewModel.kt +++ b/plugins/AI-Core/src/main/kotlin/com/itsaky/androidide/plugins/aicore/viewmodel/ChatViewModel.kt @@ -23,6 +23,16 @@ import com.itsaky.androidide.plugins.aicore.models.Sender import com.itsaky.androidide.plugins.aicore.models.isRunning import com.itsaky.androidide.plugins.aicore.models.traceLabel import com.itsaky.androidide.plugins.aicore.models.ToolResult +import com.itsaky.androidide.plugins.aicore.prompt.BackendPrompts +import com.itsaky.androidide.plugins.aicore.prompt.ContextFilesPrompt +import com.itsaky.androidide.plugins.aicore.prompt.IdeContextReader +import com.itsaky.androidide.plugins.aicore.prompt.PromptToolCatalog +import com.itsaky.androidide.plugins.aicore.prompt.ServiceBackendPrompts +import com.itsaky.androidide.plugins.aicore.prompt.SessionContext +import com.itsaky.androidide.plugins.aicore.prompt.SystemPromptFactory +import com.itsaky.androidide.plugins.aicore.prompt.ToolDescriptions +import com.itsaky.androidide.plugins.aicore.prompt.ToolResultsPrompt +import com.itsaky.androidide.plugins.aicore.prompt.config.sharedPromptConfig import com.itsaky.androidide.plugins.aicore.tool.AgentLoop import com.itsaky.androidide.plugins.aicore.tool.AgentTools import com.itsaky.androidide.plugins.aicore.tool.ApprovalRequest @@ -32,12 +42,12 @@ import com.itsaky.androidide.plugins.aicore.tool.ToolCall import com.itsaky.androidide.plugins.aicore.tool.ToolCallExtractor import com.itsaky.androidide.plugins.aicore.tool.ToolExecutionTracker import com.itsaky.androidide.plugins.aicore.tool.ToolHandler -import com.itsaky.androidide.plugins.aicore.tool.ToolSchema import com.itsaky.androidide.plugins.aicore.tool.pathsIn import com.itsaky.androidide.plugins.aicore.tool.sources.ToolSourceStore import com.itsaky.androidide.plugins.aicore.tool.handlers.BuiltInToolHandlers -import com.itsaky.androidide.plugins.aicore.tool.handlers.PathGuard -import com.itsaky.androidide.plugins.services.IdeEditorService +import com.itsaky.androidide.plugins.aicore.tool.web.BackendWebSearch +import com.itsaky.androidide.plugins.aicore.tool.web.VerificationPolicy +import com.itsaky.androidide.plugins.aicore.tool.web.WebAccess import com.itsaky.androidide.plugins.services.LlmInferenceService import com.itsaky.androidide.plugins.services.SharedServices import java.io.File @@ -103,12 +113,18 @@ class ChatViewModel( /** Sampling temperature for a backend that declares no preference of its own. */ private const val DEFAULT_TEMPERATURE = 0.2f + /** Output cap elsewhere: older OpenAI models and small servers reject a larger one. */ + private const val DEFAULT_MAX_TOKENS = 4096 + + /** + * Output cap for Gemini, whose 2.5 models spend thinking tokens from it: 4096 cut + * multi-part answers and whole-file tool calls short (ADFA-6223). + */ + private const val GEMINI_MAX_TOKENS = 16384 + /** Quiet period a debounced persist waits out; see [schedulePersist]. */ private const val PERSIST_DEBOUNCE_MS = 1_000L - /** Max open files named in the prompt's IDE-context block. */ - private const val MAX_CONTEXT_OPEN_FILES = 8 - /** * How many of a restored transcript's messages the model is given back; one exchange * spends two. See [rebuildHistoryFrom]. @@ -120,15 +136,6 @@ class ChatViewModel( * otherwise push the next send past a small local model's created context. */ private const val MAX_RESTORED_HISTORY_CHARS = 8_000 - - /** - * Path used in the tool-call examples when the IDE has nothing open, so there is no real one - * to show. A concrete path is what a small model needs to copy the *shape* from — a - * placeholder like "path/to/File.ext" measurably degrades its calls — so this is the - * dominant CoGo project layout rather than a language-neutral token. Whenever a file *is* - * open, [IdeSnapshot.exampleFilePath] uses that instead and this is never seen. - */ - private const val FALLBACK_EXAMPLE_PATH = "app/src/main/java/com/example/MainActivity.kt" } private fun getLlmService(): LlmInferenceService? { @@ -230,10 +237,47 @@ class ChatViewModel( } // Tool execution infrastructure - private val approvalManager = ToolApprovalManager() - private val agentLoop = AgentLoop(terminalTool = RESPOND_TOOL) + private val approvalManager = ToolApprovalManager(sharedPromptConfig) { handler -> + ToolDescriptions.describe(sharedPromptConfig.config(), RESPOND_TOOL, handler) + } + private val toolResultsPrompt = ToolResultsPrompt(sharedPromptConfig, RESPOND_TOOL) + private val agentLoop = AgentLoop( + formatToolResults = toolResultsPrompt, + terminalTool = RESPOND_TOOL, + unfinishedTurn = toolResultsPrompt::unfinished, + requiredToolTurn = toolResultsPrompt::requiredTool, + ) val toolExecutionTracker = ToolExecutionTracker() + /** What the active backend answers about prompts and the tool-calling protocol. */ + private val backendPrompts: BackendPrompts = ServiceBackendPrompts( + backendId = { currentBackendId }, + getService = ::getLlmService, + logWarn = { message, error -> logWarn(message, error) }, + ) + + /** Searches the web through whichever backend the run is against; see [BackendWebSearch]. */ + private val webSearch = BackendWebSearch( + config = sharedPromptConfig, + backendId = { currentBackendId }, + backend = { getLlmService()?.getBackend(currentBackendId) }, + ) + + /** Builds the system prompt for a run; see [SystemPromptFactory]. */ + private val systemPromptFactory = SystemPromptFactory( + config = sharedPromptConfig, + ideContext = IdeContextReader(getContext), + backend = backendPrompts, + session = { SessionContext.current() }, + terminalTool = RESPOND_TOOL, + toolCallSyntax = TOOL_CALL_SYNTAX, + ) + + /** Renders the user's attached files into the turn they were attached to. */ + private val contextFilesPrompt = ContextFilesPrompt(sharedPromptConfig) { message, error -> + logWarn(message, error) + } + /** This plugin's own handlers, fixed for the ViewModel's life; the contributed ones are not. */ private val builtInHandlers: List @@ -306,6 +350,26 @@ class ChatViewModel( /** Every tool name this run executed, in order, for the activity line's closing summary. */ private val runToolNames = mutableListOf() + /** Each executed call with its result, for the activity row's [ChatMessage.toolLog]. Main thread only. */ + private val runToolLog = mutableListOf() + + /** + * A reply of this run that holds code, for [reviewAnswer] to check once the run is done. + * + * @property messageId the bubble it was shown in. + * @property displayText the bubble's text. + * @property historyText what the model wrote, as the transcript keeps it. + */ + private data class CodeReply(val messageId: String, val displayText: String, val historyText: String) + + /** This run's last reply holding code; written on the stream's thread, read after the loop. */ + @Volatile + private var runCodeReply: CodeReply? = null + + /** Whether this run changed the project; its answer then reports that, and is not reviewed. */ + @Volatile + private var runChangedProject = false + /** The prompt the last run was started with, for [retryLastRun]; null before the first send. */ @Volatile private var lastRunPrompt: String? = null @@ -369,7 +433,7 @@ class ChatViewModel( fun isStorageInitialized(): Boolean = ::storageManager.isInitialized init { - builtInHandlers = getContext()?.let(BuiltInToolHandlers::create).orEmpty() + builtInHandlers = getContext()?.let { BuiltInToolHandlers.create(it, webSearch::search) }.orEmpty() agentTools = buildAgentTools() ToolSourceStore.shared.addChangeListener(toolSourcesChanged) } @@ -644,110 +708,6 @@ class ChatViewModel( setState(AgentState.Error(text)) } - /** - * Build context string from selected files. - */ - private fun buildContextString(): String { - if (contextFiles.isEmpty()) return "" - - val contextBuilder = StringBuilder() - contextBuilder.append("\n\nCONTEXT FILES:\n\n") - - contextFiles.forEach { file -> - if (file.exists() && file.isFile) { - try { - val content = file.readText() - contextBuilder.append("=== ${file.name} ===\n") - contextBuilder.append(content) - contextBuilder.append("\n\n") - } catch (e: Exception) { - logWarn("could not read context file ${file.name}", e) - } - } - } - - return contextBuilder.toString() - } - - /** - * Builds the system prompt for the active backend. - * - * The wording comes from the backend, which knows its own model; this side supplies the tool - * contract and appends the IDE context. A backend with no prompt of its own gets - * [buildDefaultSystemPrompt], so a third-party `.cgp` works without shipping prompt text. - * - * @param tools the snapshot this run is using; the prompt must describe those tools and no others. - */ - private suspend fun buildSystemPrompt(tools: AgentTools): String { - // One editor read serves both the IDE CONTEXT block and the paths in the examples. - val ide = readIdeSnapshot() - val modules = withContext(Dispatchers.IO) { - ProjectLayout.describe(File(PathGuard.projectRoot())) - } - // Paths, not a count: an empty or wrong one here is what sends the agent walking the tree, - // and these are project-relative directory names rather than the user's content. - AgentTrace.stage( - "LAYOUT", - "modules=${modules.size}" + modules.joinToString("") { - " ${it.name}[src=${it.sourceDir} layout=${it.layoutDir} manifest=${it.manifest}]" - }, - ) - val examplePath = ide.exampleFilePath() - val base = backendSystemPrompt(tools, examplePath) - ?: buildDefaultSystemPrompt(tools, examplePath) - return base + ide.contextBlock(modules) - } - - /** - * Asks the active backend for its system prompt. - * - * @param tools the snapshot this run is using. - * @param examplePath the path the tool-call examples should use. - * @return the backend's prompt, or null when it has none, is unreachable, or throws — one bad - * backend must degrade to the default prompt, not break every message - */ - private fun backendSystemPrompt(tools: AgentTools, examplePath: String): String? { - val backend = try { - getLlmService()?.getBackend(currentBackendId) - } catch (e: Throwable) { - logWarn("could not resolve backend '$currentBackendId'", e) - null - } ?: return null - - return try { - backend.getSystemPrompt( - LlmInferenceService.SystemPromptRequest( - promptToolDefinitions(tools), - // Null tells the backend this side parses no envelope; see SystemPromptRequest. - TOOL_CALL_SYNTAX.takeUnless { callsToolsNatively() }, - examplePath, - ) - )?.takeIf { it.isNotBlank() } - } catch (e: Throwable) { - logWarn( - "backend '$currentBackendId' supplied no system prompt; using the default", - e - ) - null - } - } - - /** - * Whether the active backend carries tool calls in its provider's own function-calling API - * rather than in the reply text. - * - * Decides both halves of the protocol at once — the schemas sent with the request and the - * envelope the prompt teaches — so the two can never disagree about which one is live. - * - * @return true when the backend declares [LlmInferenceService.ToolCallingBackend] - */ - private fun callsToolsNatively(): Boolean = try { - getLlmService()?.getBackend(currentBackendId) is LlmInferenceService.ToolCallingBackend - } catch (e: Throwable) { - logWarn("could not resolve backend '$currentBackendId'", e) - false - } - /** * The sampling temperature the active backend asks for. * @@ -760,66 +720,9 @@ class ChatViewModel( null } - /** - * The tools to present in the system prompt: the snapshot's budgeted list, plus [RESPOND_TOOL], - * which is not a handler but is how the model addresses the user. - * - * The cap is applied when the snapshot is built, not here, so the grammar the local backend is - * constrained by and the list the prompt describes can never disagree. It lands on this side of - * the boundary at all because every backend renders the list itself, this repo's or not. - * - * @param tools the snapshot this run is using. - * @return the definitions to hand the backend. - */ - private fun promptToolDefinitions(tools: AgentTools): List { - return tools.promptTools.definitions + LlmInferenceService.ToolDefinition( - RESPOND_TOOL, - "Send the user your reply or final answer. It MUST carry a \"message\" holding the " + - "text itself — a respond call with no \"message\" shows the user nothing.", - // Schema, not emptyMap(): under native calling a parameterless declaration is one the - // model cannot put the answer in, which is the empty respond the description warns of. - ToolSchema.objectOf( - "message" to ToolSchema.string("The reply to show the user."), - required = listOf("message"), - ), - ) - } - - /** - * Prompt used for a backend that supplies none of its own. - * - * Deliberately short: it states the protocol this side parses and nothing about model - * behaviour, which is the part only the backend can know. A backend that needs more should - * override `getSystemPrompt`. - */ - private fun buildDefaultSystemPrompt(tools: AgentTools, examplePath: String): String { - val toolDescriptions = promptToolDefinitions(tools) - .joinToString("\n") { "- ${it.name}: ${it.description}" } - - val head = """ - You are a coding assistant inside CodeOnTheGo. - - Reply with exactly ONE tool call and nothing else. After a tool call, stop and wait — the - real result arrives next turn. Never invent tool output, and never claim an action you did - not perform through a tool. For a greeting or a question you can answer directly, use - "$RESPOND_TOOL". - - Tools: - $toolDescriptions - """.trimIndent() - - // Under native calling the provider carries the call; teaching an envelope as well invites - // the model to emit both, and the text one would then run the tool a second time. - if (callsToolsNatively()) return head - - return head + "\n\n" + """ - TOOL CALL FORMAT — emit a single line in EXACTLY this format and nothing after it: - $TOOL_CALL_SYNTAX - - Example: - {"tool":"open_file","args":{"file_path":"$examplePath"}} - """.trimIndent() - } + /** How many tokens a reply may use on the current backend. */ + private fun replyTokenCap(): Int = + if (currentBackendId == AiBackend.GEMINI_ID) GEMINI_MAX_TOKENS else DEFAULT_MAX_TOKENS /** * The advice for a reply that meant to call a tool and produced nothing runnable. @@ -832,93 +735,6 @@ class ChatViewModel( ToolCallExtractor.UnparsedReply.MALFORMED -> R.string.agent_reply_malformed } - /** - * What the IDE has open, project-relative, read once per prompt. - * @property currentFile the focused file, or null when nothing is open. - * @property otherFiles other open tabs, capped at [MAX_CONTEXT_OPEN_FILES]. - */ - private data class IdeSnapshot(val currentFile: String?, val otherFiles: List) - - /** - * Reads the open-file state from the editor service. - * @return the snapshot; empty when there is no editor service or the call fails. - */ - private suspend fun readIdeSnapshot(): IdeSnapshot { - val editor = getContext()?.services?.get(IdeEditorService::class.java) - ?: return IdeSnapshot(null, emptyList()) - val root = File(PathGuard.projectRoot()) - - // Editor state is read on the main thread, like every other editor-service call here. - val (current, open) = withContext(Dispatchers.Main) { - runCatching { editor.getCurrentFile() to editor.getOpenFiles() } - .getOrDefault(null to emptyList()) - } - - fun relative(file: File): String = runCatching { file.relativeToOrSelf(root).path } - .getOrDefault(file.name) - - return IdeSnapshot( - currentFile = current?.let(::relative), - otherFiles = open.orEmpty() - .filter { it != current } - .take(MAX_CONTEXT_OPEN_FILES) - .map(::relative), - ) - } - - /** - * The path the tool-call examples should use: a file the IDE really has open, so the examples - * carry this project's own language and layout instead of teaching an Android/Java one. Falls - * back to [FALLBACK_EXAMPLE_PATH] only when nothing is open. - * @return a project-relative path. - */ - private fun IdeSnapshot.exampleFilePath(): String = - currentFile ?: otherFiles.firstOrNull() ?: FALLBACK_EXAMPLE_PATH - - /** - * Describes what the user is looking at: the focused file and other open tabs, project-relative, - * plus where [modules] keep their code. Most requests are about the file on screen and the IDE - * knows that path exactly; without it the model reconstructs one, which is where invented - * `.java` paths for Kotlin files came from. - * - * @param modules the project's modules, so the agent spends no turns rediscovering them. - * @return a prompt block, or empty when there is nothing to say. - */ - private fun IdeSnapshot.contextBlock(modules: List): String { - if (currentFile == null && otherFiles.isEmpty() && modules.isEmpty()) return "" - - return buildString { - append("\n\nIDE CONTEXT (real paths — use these verbatim, do not rewrite them):\n") - currentFile?.let { append("- File the user is viewing: ").append(it).append("\n") } - if (otherFiles.isNotEmpty()) { - append("- Other open files: ").append(otherFiles.joinToString(", ")).append("\n") - } - for (module in modules) { - module.sourceDir?.let { - append("- New classes for module '").append(module.name).append("' go in: ") - .append(it).append("\n") - } - module.layoutDir?.let { - append("- Layouts for module '").append(module.name).append("': ") - .append(it).append("\n") - } - module.manifest?.let { - append("- Manifest for module '").append(module.name).append("': ") - .append(it).append("\n") - } - } - if (modules.isNotEmpty()) { - append( - "These directories already exist — do not call list_files to rediscover them.\n" - ) - } - append( - "If the user names a file that appears above, use that exact path and do not " + - "guess a different folder or extension." - ) - } - } - /** * Executes a batch of tool calls and returns the results for the [agentLoop] to feed back; * leaves [AgentState.Idle] to the loop. @@ -948,11 +764,20 @@ class ChatViewModel( val results = tools.executor.execute(toolCalls) { call -> withContext(Dispatchers.Main) { showActivity(call) } } + if (toolCalls.any { tools.router.getHandler(it.name)?.mutatesProject == true }) { + runChangedProject = true + } // Record whether this batch's last tool failed (read by runModelTurn). lastToolFailedThisRun = results.lastOrNull()?.success == false withContext(Dispatchers.Main) { + results.forEachIndexed { index, result -> + toolCalls.getOrNull(index)?.let { runToolLog += AgentActivity.logEntry(it, result) } + } + activityMessageId?.let { id -> + _messages.value.firstOrNull { it.id == id }?.let { putActivity(it.text, it.status) } + } results.forEachIndexed { index, result -> if (result.success) return@forEachIndexed val toolCall = toolCalls[index] @@ -1101,6 +926,9 @@ class ChatViewModel( lastToolFailedThisRun = false activityMessageId = null runToolNames.clear() + runToolLog.clear() + runCodeReply = null + runChangedProject = false // Where a Retry has to rewind to; read here, on Main, while no run can be appending. lastRunPrompt = userMessage historySizeBeforeLastRun = _history.value.size @@ -1130,18 +958,23 @@ class ChatViewModel( // Queued behind a title still being written; the prompt shows as generating meanwhile. awaitTitleRequest() + // One list for both halves of the protocol: the prompt describes it and a + // natively calling backend is sent it, so the two can never name different tools. + val toolDefinitions = + PromptToolCatalog.definitions(tools, RESPOND_TOOL, sharedPromptConfig.config()) + val config = LlmInferenceService.LlmConfig(currentBackendId).apply { // The grammar shapes a local tool call but not its values, so paths get sampled. temperature = backendTemperature() ?: DEFAULT_TEMPERATURE - maxTokens = 4096 // headroom for complete tool calls - systemPrompt = buildSystemPrompt(tools) + maxTokens = replyTokenCap() + systemPrompt = systemPromptFactory.create(toolDefinitions) // Local backend constrains generation to this grammar; cloud ignores it. extraParams = mapOf(EXTRA_PARAM_GRAMMAR to tools.grammar) } val messageWithContext = buildString { append(userMessage) - append(buildContextString()) + append(contextFilesPrompt.render(contextFiles)) } val history = _history.value.toMutableList() history.add( @@ -1150,17 +983,22 @@ class ChatViewModel( messageWithContext ) ) + // Code to judge, or a question about what is current: search before answering. + val requiredTool = WebAccess.WEB_SEARCH_TOOL.takeIf { search -> + currentBackendId != AiBackend.LOCAL_ID && + toolDefinitions.any { it.name == search } && + VerificationPolicy.requiresWebCheck(userMessage, contextFiles.isNotEmpty()) + } + requiredTool?.let { AgentTrace.stage("VERIFY", "required=$it on the first turn") } + var firstTurn = true try { - // The same list the system prompt describes, so a native declaration and the - // prose the model reads can never name different tools. - val toolDefinitions = promptToolDefinitions(tools) // Which protocol is live for this run. `native=false` against a backend that // should call natively is the first thing to check when a call reaches the chat // as text instead of running. AgentTrace.stage( "PROTOCOL", - "native=${callsToolsNatively()} tools=${toolDefinitions.size} " + + "native=${backendPrompts.callsToolsNatively()} tools=${toolDefinitions.size} " + toolDefinitions.joinToString(",") { it.name }, ) val loopResult = agentLoop.run( @@ -1169,7 +1007,10 @@ class ChatViewModel( withContext(Dispatchers.Main) { setState(AgentState.Processing(str(R.string.msg_generating))) } - runModelTurn(llmService, turns, config, toolDefinitions, epoch) + val turnConfig = requiredTool?.takeIf { firstTurn } + ?.let { VerificationPolicy.requiring(config, it) } ?: config + firstTurn = false + runModelTurn(llmService, turns, turnConfig, toolDefinitions, epoch) }, executeTools = { calls -> executeToolCalls(tools, calls) }, // Read through the handler, so a path spelled `path` or left to a default @@ -1180,8 +1021,12 @@ class ChatViewModel( changesPaths = { call -> tools.router.getHandler(call.name)?.mutatesProject == true }, + requiredTool = requiredTool, events = AgentRunReporter(runNotices), ) + if (loopResult.completed && generationEpoch.get() == epoch) { + runCodeReply?.let { draft -> reviewAnswer(llmService, userMessage, draft, history, epoch) } + } AgentTrace.endRun(loopResult.reason.name, loopResult.turns) if (loopResult.completed && generationEpoch.get() == epoch) { titleRequestStarted = withContext(Dispatchers.Main) { @@ -1224,6 +1069,73 @@ class ChatViewModel( return true } + /** + * Checks [draft] in a second request and, when that returns a corrected answer, shows it in + * the draft's bubble and keeps it in [history] in the draft's place. Every other outcome — a + * failure, a timeout, a reply cut off before its end marker — leaves the draft as it was. + * + * Skipped when the run changed the project, since that answer reports work already done, and + * on the on-device backend, where writing the answer a second time takes minutes. + * + * @param llmService the inference service the run used. + * @param request what the user asked. + * @param draft the run's last reply holding code. + * @param history the run's transcript, updated in place. + * @param epoch the run's epoch; a Stop or a newer message makes the result stale. + */ + private suspend fun reviewAnswer( + llmService: LlmInferenceService, + request: String, + draft: CodeReply, + history: MutableList, + epoch: Int, + ) { + if (runChangedProject || currentBackendId == AiBackend.LOCAL_ID) return + withContext(Dispatchers.Main) { setState(AgentState.Processing(str(R.string.msg_reviewing))) } + val evidence = withContext(Dispatchers.Main) { runToolLog.joinToString("\n\n") } + val started = System.currentTimeMillis() + val response = try { + val prompts = sharedPromptConfig.config() + val config = LlmInferenceService.LlmConfig(currentBackendId).apply { + temperature = AnswerReview.TEMPERATURE + maxTokens = replyTokenCap() + systemPrompt = AnswerReview.systemPrompt(prompts, SessionContext.current().currentTime) + } + withTimeoutOrNull(AnswerReview.TIMEOUT_MS) { + llmService.generateCompletion( + AnswerReview.prompt(prompts, request, evidence, draft.displayText), + config, + ).await() + } + } catch (ce: CancellationException) { + throw ce + } catch (e: Exception) { + logWarn("answer review failed", e) + return + } + val ms = System.currentTimeMillis() - started + if (response == null || !response.success) { + AgentTrace.stage("REVIEW", "kept draft ms=$ms (${response?.error ?: "timed out"})") + return + } + val corrected = AnswerReview.corrected(response.text.orEmpty(), draft.displayText) + AgentTrace.stage("REVIEW", "changed=${corrected != null} ms=$ms chars=${corrected?.length ?: 0}") + if (corrected == null || generationEpoch.get() != epoch) return + // The draft's turn, so a follow-up question builds on the answer the user was shown. + val turn = history.indexOfLast { + it.role == LlmInferenceService.ChatMessage.Role.ASSISTANT && it.content == draft.historyText + } + if (turn >= 0) { + history[turn] = LlmInferenceService.ChatMessage(LlmInferenceService.ChatMessage.Role.ASSISTANT, corrected) + } + withContext(Dispatchers.Main) { + val shown = _messages.value.firstOrNull { it.id == draft.messageId } ?: return@withContext + val updated = shown.copy(text = corrected, historyText = null) + _messages.value = _messages.value.map { if (it.id == draft.messageId) updated else it } + syncMessageToSession(updated) + } + } + /** * Asks the selected backend to name the current chat, once, after its first completed reply. * Leaves chats the user named, or that already have a title, alone. Main-thread only; the @@ -1286,14 +1198,16 @@ class ChatViewModel( userText: String, replyText: String, ) { - val config = LlmInferenceService.LlmConfig(currentBackendId).apply { - temperature = ChatTitle.TEMPERATURE - maxTokens = ChatTitle.MAX_TOKENS - systemPrompt = ChatTitle.SYSTEM_PROMPT - } val response = try { + // Inside the try: a config that failed to load costs the title, not the chat. + val prompts = sharedPromptConfig.config() + val config = LlmInferenceService.LlmConfig(currentBackendId).apply { + temperature = ChatTitle.TEMPERATURE + maxTokens = ChatTitle.MAX_TOKENS + systemPrompt = ChatTitle.systemPrompt(prompts) + } withTimeoutOrNull(ChatTitle.TIMEOUT_MS) { - llmService.generateCompletion(ChatTitle.prompt(userText, replyText), config).await() + llmService.generateCompletion(ChatTitle.prompt(prompts, userText, replyText), config).await() } } catch (ce: CancellationException) { throw ce @@ -1357,7 +1271,7 @@ class ChatViewModel( * @param llmService the inference service. * @param turns the conversation so far; the last entry is the current user turn. * @param config the generation config. - * @param toolDefinitions the tools to offer a natively-calling backend; see [callsToolsNatively]. + * @param toolDefinitions the tools to offer a natively-calling backend; see [BackendPrompts.callsToolsNatively]. * @param epoch this run's epoch, for staleness checks against Stop/newer sends. * @return the turn: the reply with any native calls rendered into it for extraction, beside the * text the model itself wrote, which is what the transcript keeps. @@ -1461,6 +1375,9 @@ class ChatViewModel( noResponseText = str(R.string.agent_no_response), unparsedReplyText = { str(unparsedReplyMessage(it)) }, ) + if (AnswerReview.holdsCode(displayText)) { + runCodeReply = CodeReply(agentMessageId, displayText, reply.historyText) + } viewModelScope.launch(Dispatchers.Main) { if (isStale()) return@launch val finalMsg = ChatMessage( @@ -1615,6 +1532,7 @@ class ChatViewModel( } activityMessageId = null runToolNames.clear() + runToolLog.clear() } /** @@ -1635,6 +1553,7 @@ class ChatViewModel( status = status, // Any non-null value: a null one is what the adapter animates generating-dots on. durationMs = 0L, + toolLog = runToolLog.takeIf { it.isNotEmpty() }?.joinToString("\n\n"), ) activityMessageId = message.id _messages.value = if (existing) { diff --git a/plugins/AI-Core/src/main/res/layout/fragment_ai_settings.xml b/plugins/AI-Core/src/main/res/layout/fragment_ai_settings.xml index 1cbc6836..e7e492e9 100644 --- a/plugins/AI-Core/src/main/res/layout/fragment_ai_settings.xml +++ b/plugins/AI-Core/src/main/res/layout/fragment_ai_settings.xml @@ -59,6 +59,15 @@ android:minHeight="48dp" tools:ignore="TouchTargetSizeCheck" /> + + + Please enter a message Copied Generating… + Checking the answer… Copy Text @@ -170,6 +171,7 @@ AI Settings Close settings AI Backend + The Agent can search the web and read pages you link. Searches use your API key and may be billed by your provider. Backend Model diff --git a/plugins/AI-Core/src/test/kotlin/com/itsaky/androidide/plugins/aicore/models/ChatTranscriptTest.kt b/plugins/AI-Core/src/test/kotlin/com/itsaky/androidide/plugins/aicore/models/ChatTranscriptTest.kt index 21e9a551..42372b6a 100644 --- a/plugins/AI-Core/src/test/kotlin/com/itsaky/androidide/plugins/aicore/models/ChatTranscriptTest.kt +++ b/plugins/AI-Core/src/test/kotlin/com/itsaky/androidide/plugins/aicore/models/ChatTranscriptTest.kt @@ -98,6 +98,44 @@ class ChatTranscriptTest { assertEquals(original.messages.map { it.historyText }, imported.messages.map { it.historyText }) } + @Test + fun givenAnActivityRowWithAToolLog_whenExported_thenTheSearchAndItsResultAreInTheFile() { + val log = "✓ web_search(query=Ktor JsonFeature)\nSearched the web for: Ktor JsonFeature\nRemoved in 2.0." + val original = ChatSession(messages = listOf(message("✓ 1 action · web_search", Sender.TOOL, toolLog = log))) + + val exported = ChatTranscript.export(original) + + assertTrue(exported.contains("--- CALLS MADE\n$log\n")) + } + + @Test + fun givenBothAHistoryAndAToolLog_whenRoundTripped_thenEachLandsInItsOwnField() { + val original = ChatSession( + messages = listOf( + message("bubble", Sender.AGENT, durationMs = 5, historyText = "wrote\n\nthis", toolLog = "log\n\nlines"), + message("✓ 1 action", Sender.TOOL, durationMs = 0, toolLog = "only a log"), + message("Why?", Sender.USER), + ) + ) + + val imported = ChatTranscript.parse(ChatTranscript.export(original), null) + + assertEquals(original.messages.map { it.text }, imported.messages.map { it.text }) + assertEquals(original.messages.map { it.historyText }, imported.messages.map { it.historyText }) + assertEquals(original.messages.map { it.toolLog }, imported.messages.map { it.toolLog }) + } + + @Test + fun givenAMessageThatLooksLikeTheCallsMarker_whenRoundTripped_thenItIsNotSplit() { + val tricky = "a\n--- CALLS MADE\nb" + val original = ChatSession(messages = listOf(message(tricky, Sender.AGENT, toolLog = tricky))) + + val imported = ChatTranscript.parse(ChatTranscript.export(original), null).messages.single() + + assertEquals(tricky, imported.text) + assertEquals(tricky, imported.toolLog) + } + @Test fun givenAMessageThatLooksLikeTheHistoryMarker_whenRoundTripped_thenItIsNotSplit() { val tricky = "a\n--- MODEL WROTE\n\\--- MODEL WROTE \nb" @@ -286,8 +324,9 @@ class ChatTranscriptTest { durationMs: Long? = null, status: MessageStatus = MessageStatus.SENT, historyText: String? = null, + toolLog: String? = null, ) = ChatMessage( text = text, sender = sender, timestamp = timestamp, durationMs = durationMs, status = status, - historyText = historyText, + historyText = historyText, toolLog = toolLog, ) } diff --git a/plugins/AI-Core/src/test/kotlin/com/itsaky/androidide/plugins/aicore/prompt/ApprovalPromptTest.kt b/plugins/AI-Core/src/test/kotlin/com/itsaky/androidide/plugins/aicore/prompt/ApprovalPromptTest.kt new file mode 100644 index 00000000..bb318de4 --- /dev/null +++ b/plugins/AI-Core/src/test/kotlin/com/itsaky/androidide/plugins/aicore/prompt/ApprovalPromptTest.kt @@ -0,0 +1,65 @@ +package com.itsaky.androidide.plugins.aicore.prompt + +import com.itsaky.androidide.plugins.aicore.prompt.config.DirectoryPromptConfigSource.Companion.shippedConfig +import com.itsaky.androidide.plugins.aicore.prompt.config.DirectoryPromptConfigSource.Companion.shippedWith +import org.junit.Assert.assertEquals +import org.junit.Test + +/** Unit tests for [ApprovalPrompt], what the agent is told when the user does not approve a call. */ +class ApprovalPromptTest { + + @Test + fun givenTheShippedConfig_whenChecked_thenEveryMessageRenders() { + assertEquals(emptyList(), ApprovalPrompt.problems(shippedConfig)) + } + + @Test + fun givenADenial_whenRendering_thenItNamesTheTool() { + assertEquals( + "User denied permission to execute edit_file", + ApprovalPrompt.denied(shippedConfig, "edit_file"), + ) + } + + @Test + fun givenACorrectionWithText_whenRendering_thenTheInstructionIsRelayedTrimmed() { + assertEquals( + "User rejected this edit_file call and asked you to revise it: \"keep the name\". " + + "Apply that instruction and try again.", + ApprovalPrompt.corrected(shippedConfig, "edit_file", " keep the name "), + ) + } + + @Test + fun givenACorrectionWithNoText_whenRendering_thenItStillAsksForARevision() { + assertEquals( + "User rejected this edit_file call and asked you to revise it.", + ApprovalPrompt.corrected(shippedConfig, "edit_file", " "), + ) + } + + @Test + fun givenAnInstructionThatLooksLikeATemplate_whenRendering_thenItIsRelayedVerbatim() { + // What the user typed is data; rendering it would throw on a stray tag. + val message = ApprovalPrompt.corrected(shippedConfig, "edit_file", "use {{NAME}}") + + assertEquals(true, message.contains("\"use {{NAME}}\"")) + } + + @Test + fun givenATimeout_whenRendering_thenItStatesTheWait() { + assertEquals( + "Approval request timed out (no response within 5 minutes). Please try again.", + ApprovalPrompt.timedOut(shippedConfig, "edit_file", 5), + ) + } + + @Test + fun givenANameTypo_whenChecked_thenTheProblemNamesTheText() { + val config = shippedWith("agent_loop.yml") { it.replace("{{MINUTES}}", "{{MINUTE}}") } + + val problems = ApprovalPrompt.problems(config) + + assertEquals(listOf("agent_loop.yml: approval.timed_out: unknown name {{MINUTE}}"), problems) + } +} diff --git a/plugins/AI-Core/src/test/kotlin/com/itsaky/androidide/plugins/aicore/prompt/ContextFilesPromptTest.kt b/plugins/AI-Core/src/test/kotlin/com/itsaky/androidide/plugins/aicore/prompt/ContextFilesPromptTest.kt new file mode 100644 index 00000000..d4a2ca69 --- /dev/null +++ b/plugins/AI-Core/src/test/kotlin/com/itsaky/androidide/plugins/aicore/prompt/ContextFilesPromptTest.kt @@ -0,0 +1,94 @@ +package com.itsaky.androidide.plugins.aicore.prompt + +import com.itsaky.androidide.plugins.aicore.prompt.config.DirectoryPromptConfigSource.Companion.shippedConfig +import com.itsaky.androidide.plugins.aicore.prompt.config.DirectoryPromptConfigSource.Companion.shippedWith +import java.io.File +import kotlinx.coroutines.runBlocking +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Rule +import org.junit.Test +import org.junit.rules.TemporaryFolder + +/** Unit tests for [ContextFilesPrompt], the block carrying the files the user attached. */ +class ContextFilesPromptTest { + + @get:Rule + val folder = TemporaryFolder() + + private val warnings = mutableListOf() + private val prompt = ContextFilesPrompt({ shippedConfig }) { message, _ -> warnings.add(message) } + + @Test + fun givenTheShippedConfig_whenChecked_thenTheBlockRenders() { + assertEquals(emptyList(), ContextFilesPrompt.problems(shippedConfig)) + } + + @Test + fun givenTwoAttachedFiles_whenRendering_thenEachIsFramedUnderOneHeading() { + val first = folder.newFile("A.kt").apply { writeText("val a = 1") } + val second = folder.newFile("B.kt").apply { writeText("val b = 2") } + + val block = render(listOf(first, second)) + + assertEquals("\n\nCONTEXT FILES:\n\n=== A.kt ===\nval a = 1\n\n=== B.kt ===\nval b = 2\n\n", block) + } + + @Test + fun givenAFileWhoseTextLooksLikeATemplate_whenRendering_thenItIsInsertedVerbatim() { + // The user's own content is data; rendering it would throw on a stray tag or rewrite it. + val file = folder.newFile("T.peb").apply { writeText("{{APP_NAME}} {{#X}}") } + + assertTrue(render(listOf(file)).contains("{{APP_NAME}} {{#X}}")) + } + + @Test + fun givenANameTypoInTheLayout_whenChecked_thenTheProblemNamesTheText() { + val config = shippedWith("layout.yml") { it.replace("{{CONTEXT_FILES_HEADING}}", "{{CONTEXT_FILE_HEADING}}") } + + val problems = ContextFilesPrompt.problems(config) + + assertEquals(listOf("layout.yml: layout.context_files: unknown name {{CONTEXT_FILE_HEADING}}"), problems) + } + + @Test + fun givenNoAttachments_whenRendering_thenNothingIsAppended() { + assertEquals("", render(emptyList())) + } + + @Test + fun givenAnAttachedFile_whenRendering_thenItsNameAndContentsAreIncluded() { + val file = folder.newFile("Notes.kt").apply { writeText("val x = 1") } + + val block = render(listOf(file)) + + assertTrue(block.contains("=== Notes.kt ===")) + assertTrue(block.contains("val x = 1")) + } + + @Test + fun givenOnlyFilesThatCannotBeRead_whenRendering_thenNoEmptyHeadingIsAppended() { + // A heading with nothing under it is prompt the model reads and cannot use. + assertEquals("", render(listOf(File(folder.root, "gone.kt")))) + } + + @Test + fun givenAFileThatNoLongerExists_whenRendering_thenItIsReported() { + // Silence here once let a deleted attachment look like one the model had ignored. + render(listOf(File(folder.root, "gone.kt"))) + + assertTrue(warnings.any { it.contains("gone.kt") }) + } + + @Test + fun givenAFileThatNoLongerExists_whenRendering_thenTheOthersStillRender() { + // A stale attachment must not cost the user the send; the message is worth answering. + val present = folder.newFile("Present.kt").apply { writeText("kept") } + + val block = render(listOf(File(folder.root, "gone.kt"), present)) + + assertTrue(block.contains("kept")) + } + + private fun render(files: List): String = runBlocking { prompt.render(files) } +} diff --git a/plugins/AI-Core/src/test/kotlin/com/itsaky/androidide/plugins/aicore/prompt/DefaultSystemPromptTest.kt b/plugins/AI-Core/src/test/kotlin/com/itsaky/androidide/plugins/aicore/prompt/DefaultSystemPromptTest.kt new file mode 100644 index 00000000..fedd8590 --- /dev/null +++ b/plugins/AI-Core/src/test/kotlin/com/itsaky/androidide/plugins/aicore/prompt/DefaultSystemPromptTest.kt @@ -0,0 +1,148 @@ +package com.itsaky.androidide.plugins.aicore.prompt + +import com.itsaky.androidide.plugins.aicore.prompt.config.DirectoryPromptConfigSource.Companion.shippedConfig +import com.itsaky.androidide.plugins.services.LlmInferenceService.SystemPromptRequest +import com.itsaky.androidide.plugins.services.LlmInferenceService.ToolDefinition +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +/** + * Unit tests for the prompt the shipped prompt config renders, the one a backend that ships none of + * its own gets, rendered exactly as [SystemPromptFactory] renders it. + */ +class DefaultSystemPromptTest { + + private val tools = listOf( + ToolDefinition("read_file", "Read a file.", emptyMap()), + ToolDefinition("respond", "Answer the user.", emptyMap()), + ) + + private fun request(syntax: String?) = SystemPromptRequest(tools, syntax, "app/Main.kt") + + @Test + fun givenATextProtocol_whenBuilding_thenTheEnvelopeAndTheExamplePathAreTaught() { + val prompt = build(request(SYNTAX), "respond") + + assertTrue(prompt.contains(SYNTAX)) + assertTrue(prompt.contains("app/Main.kt")) + } + + @Test + fun givenNativeToolCalling_whenBuilding_thenNoEnvelopeIsTaught() { + // Teaching an envelope as well invites both, and the text one runs the tool a second time. + val prompt = build(request(null), "respond") + + assertFalse(prompt.contains("TOOL CALL FORMAT")) + assertFalse(prompt.contains("")) + } + + @Test + fun givenABlankEnvelope_whenBuilding_thenNoFormatSectionIsTaught() { + // "emit EXACTLY this format:" with nothing after it teaches the model to emit nothing. + val prompt = build(request(" "), "respond") + + assertFalse(prompt.contains("TOOL CALL FORMAT")) + } + + @Test + fun givenAnyProtocol_whenBuilding_thenEveryToolIsDescribed() { + val prompt = build(request(SYNTAX), "respond") + + assertTrue(prompt.contains("- read_file: Read a file.")) + assertTrue(prompt.contains("- respond: Answer the user.")) + } + + @Test + fun givenARenamedTerminalTool_whenBuilding_thenTheNewNameIsTheOneTaught() { + // The name is the ViewModel's to choose; a prompt that hardcodes "respond" would teach a + // tool the run does not offer. + val prompt = build(request(SYNTAX), "answer") + + assertTrue(prompt.contains("\"answer\"")) + } + + @Test + fun givenAnyProtocol_whenBuilding_thenTheIdentityIsFollowedByTheRulesThenTheCapabilities() { + val prompt = build(request(SYNTAX), "respond") + + assertTrue(prompt.startsWith("You are a coding assistant inside CodeOnTheGo.")) + assertTrue(prompt.indexOf("CodeOnTheGo.") < prompt.indexOf("Reply with exactly ONE")) + assertTrue(prompt.indexOf("Reply with exactly ONE") < prompt.indexOf("Tools:\n- read_file")) + assertTrue(prompt.indexOf("- respond: Answer the user.") < prompt.indexOf("TOOL CALL FORMAT")) + } + + @Test + fun givenNativeToolCalling_whenBuilding_thenTheAbsentFormatLeavesNoTrailingBlankLines() { + val prompt = build(request(null), "respond") + + // The session lines follow the tool list after exactly one blank line. + assertTrue(prompt.contains("- respond: Answer the user.\n\nCurrent date and time")) + assertFalse(prompt.contains("\n\n\n")) + } + + @Test + fun givenTheShippedRules_whenRead_thenThePrioritiesAreKnownAndInOrder() { + // A heading the model has not been taught has no weight, and a lower one first misleads it. + val headings = shippedConfig.rules.map { it.heading.template } + + assertTrue(headings.all { it in PRIORITIES }) + assertEquals(headings.sortedBy { PRIORITIES.indexOf(it) }, headings) + } + + @Test + fun givenSeveralPriorities_whenBuilding_thenOneBlankLineSeparatesThemAndNoneTrails() { + val prompt = build(request(SYNTAX), "respond") + + assertTrue(prompt.contains("nothing else.\n- Never invent")) + assertTrue(prompt.contains("through a tool.\n\nIMPORTANT:\n")) + assertTrue(prompt.contains("use \"respond\".\n\nTools:\n")) + } + + @Test + fun givenAnyProtocol_whenBuilding_thenTheCriticalRulesComeFirst() { + val prompt = build(request(SYNTAX), "respond") + + assertTrue(prompt.indexOf("CRITICAL:") < prompt.indexOf("- Reply with exactly ONE tool call")) + assertTrue(prompt.indexOf("- Reply with exactly ONE tool call") < prompt.indexOf("IMPORTANT:")) + } + + @Test + fun givenAnOffDomainQuestion_whenBuilding_thenTheNonRefusalRuleIsStated() { + // The regression ADFA-6223 fixed: without this the model refused anything non-Android. + val prompt = build(request(SYNTAX), "respond") + + assertTrue(prompt.contains("Never refuse a question because it is not about Android")) + } + + @Test + fun givenAnyProtocol_whenBuilding_thenNoSentenceIsBrokenAcrossLines() { + // Source line wraps once reached the model as literal newlines mid-sentence. + val prompt = build(request(SYNTAX), "respond") + + assertFalse(prompt.contains("not\nonly Android")) + assertTrue(prompt.contains("You answer anything the user asks, not only Android questions.")) + } + + @Test + fun givenSeveralTools_whenBuilding_thenNoLineIsIndented() { + // An interpolated multi-line tool list defeated trimIndent and left every line indented. + val prompt = build(request(SYNTAX), "respond") + + assertFalse(prompt.lines().any { it.startsWith(" ") }) + } + + private fun build(request: SystemPromptRequest, terminalTool: String): String { + return SystemPromptRenderer.render(shippedConfig, request, terminalTool, IdeContext.EMPTY, SESSION) + } + + private companion object { + val SESSION = SessionContext("Monday, 1 January 2029, 09:00 (UTC, UTC+00:00)") + + /** The rule priorities, highest first; `rules.yml` may use only these headings. */ + val PRIORITIES = listOf("CRITICAL", "IMPORTANT", "MANDATORY", "OPTIONAL") + + const val SYNTAX = """{"tool":"TOOL_NAME","args":{"arg":"value"}}""" + } +} diff --git a/plugins/AI-Core/src/test/kotlin/com/itsaky/androidide/plugins/aicore/prompt/IdeContextBlockTest.kt b/plugins/AI-Core/src/test/kotlin/com/itsaky/androidide/plugins/aicore/prompt/IdeContextBlockTest.kt new file mode 100644 index 00000000..7359d2df --- /dev/null +++ b/plugins/AI-Core/src/test/kotlin/com/itsaky/androidide/plugins/aicore/prompt/IdeContextBlockTest.kt @@ -0,0 +1,112 @@ +package com.itsaky.androidide.plugins.aicore.prompt + +import com.itsaky.androidide.plugins.aicore.prompt.config.DirectoryPromptConfigSource.Companion.shippedConfig +import org.junit.Assert.assertFalse +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test + +/** Unit tests for the shipped IDE CONTEXT block (`ide_context.yml`, `layout.yml`), which every prompt ends with. */ +class IdeContextBlockTest { + + @Test + fun givenNothingOpenAndNoModules_whenRendering_thenOnlyTheSessionLinesRemain() { + // No facts drops the IDE CONTEXT block, heading and all; the clock is always stated. + val block = SystemPromptRenderer.renderIdeContext(shippedConfig, IdeContext.EMPTY, SESSION) + + assertEquals(2, block.lines().size) + assertFalse(block.contains("IDE CONTEXT")) + } + + @Test + fun givenAnySession_whenRendering_thenTheDeviceTimeIsStatedFirst() { + // Without it every model answers "what time is it" with "I have no access to the time". + val block = SystemPromptRenderer.renderIdeContext(shippedConfig, IdeContext.EMPTY, SESSION) + + assertEquals("Current date and time on the user's device: $TIME", block.lines().first()) + } + + @Test + fun givenAnyRun_whenRendering_thenTheWebToolsAreNamedAndRefusalIsForbidden() { + val block = SystemPromptRenderer.renderIdeContext(shippedConfig, IdeContext.EMPTY, SESSION) + + assertTrue(block.contains("call web_search")) + assertTrue(block.contains("call fetch_url")) + assertTrue(block.contains("Never say you cannot access the internet.")) + } + + @Test + fun givenOnlyAFocusedFile_whenRendering_thenNoModuleLineOrBlankLineAppears() { + val block = facts(IdeContext("app/Main.kt", emptyList(), emptyList())) + + assertEquals( + listOf( + "IDE CONTEXT (real paths — use these verbatim, do not rewrite them):", + "- File the user is viewing: app/Main.kt", + "If the user names a file that appears above, use that exact path and do not " + + "guess a different folder or extension.", + ), + // Past the session lines and the blank line that separates them from the block. + block.lines().drop(3), + ) + } + + @Test + fun givenAFocusedFile_whenRendering_thenItsExactPathIsNamed() { + // Without it the model reconstructs a path, which is where invented `.java` paths for + // Kotlin files came from. + val block = facts(IdeContext("app/Main.kt", emptyList(), emptyList())) + + assertTrue(block.contains("- File the user is viewing: app/Main.kt")) + } + + @Test + fun givenOtherOpenTabs_whenRendering_thenTheyAreListedTogether() { + val block = facts( + IdeContext("app/Main.kt", listOf("lib/A.kt", "lib/B.kt"), emptyList()) + ) + + assertTrue(block.contains("- Other open files: lib/A.kt, lib/B.kt")) + } + + @Test + fun givenAModule_whenRendering_thenItsSourceLayoutAndManifestAreNamed() { + val block = facts(IdeContext(null, emptyList(), listOf(module()))) + + assertTrue(block.contains("- New classes for module 'app' go in: app/src/main/java")) + assertTrue(block.contains("- Layouts for module 'app': app/src/main/res/layout")) + assertTrue(block.contains("- Manifest for module 'app': app/src/main/AndroidManifest.xml")) + } + + @Test + fun givenAModule_whenRendering_thenRediscoveringItIsForbidden() { + // The seven-of-sixteen-turn tree walk this block exists to prevent. + val block = facts(IdeContext(null, emptyList(), listOf(module()))) + + assertTrue(block.contains("do not call list_files to rediscover them")) + } + + @Test + fun givenAModuleWithNoLayoutDir_whenRendering_thenNoLayoutLineIsInvented() { + val block = facts( + IdeContext(null, emptyList(), listOf(module().copy(layoutDir = null))) + ) + + assertFalse(block.contains("Layouts for module")) + } + + private fun facts(context: IdeContext): String = + SystemPromptRenderer.renderIdeContext(shippedConfig, context, SESSION) + + private companion object { + const val TIME = "Monday, 1 January 2029, 09:00 (UTC, UTC+00:00)" + val SESSION = SessionContext(TIME) + } + + private fun module() = ProjectLayout.Module( + name = "app", + sourceDir = "app/src/main/java", + layoutDir = "app/src/main/res/layout", + manifest = "app/src/main/AndroidManifest.xml", + ) +} diff --git a/plugins/AI-Core/src/test/kotlin/com/itsaky/androidide/plugins/aicore/prompt/IdeContextTest.kt b/plugins/AI-Core/src/test/kotlin/com/itsaky/androidide/plugins/aicore/prompt/IdeContextTest.kt new file mode 100644 index 00000000..d95e4b0b --- /dev/null +++ b/plugins/AI-Core/src/test/kotlin/com/itsaky/androidide/plugins/aicore/prompt/IdeContextTest.kt @@ -0,0 +1,50 @@ +package com.itsaky.androidide.plugins.aicore.prompt + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +/** Unit tests for [IdeContext], the value the prompt's IDE knowledge is carried in. */ +class IdeContextTest { + + @Test + fun givenAFocusedFile_whenChoosingTheExamplePath_thenItIsUsed() { + // The examples then carry this project's own language and layout. + val context = IdeContext("app/Main.kt", listOf("app/Other.kt"), emptyList()) + + assertEquals("app/Main.kt", context.exampleFilePath) + } + + @Test + fun givenOnlyOtherOpenTabs_whenChoosingTheExamplePath_thenTheFirstTabIsUsed() { + val context = IdeContext(null, listOf("lib/Util.kt", "app/Main.kt"), emptyList()) + + assertEquals("lib/Util.kt", context.exampleFilePath) + } + + @Test + fun givenNothingOpen_whenChoosingTheExamplePath_thenTheFallbackIsUsed() { + assertEquals(IdeContext.FALLBACK_EXAMPLE_PATH, IdeContext.EMPTY.exampleFilePath) + } + + @Test + fun givenNothingOpenAndNoModules_whenAskedIfEmpty_thenItIs() { + assertTrue(IdeContext.EMPTY.isEmpty) + } + + @Test + fun givenModulesButNothingOpen_whenAskedIfEmpty_thenItIsNot() { + // The module paths alone are worth a context block; they are what stops the tree walking. + val context = IdeContext(null, emptyList(), listOf(module())) + + assertFalse(context.isEmpty) + } + + private fun module() = ProjectLayout.Module( + name = "app", + sourceDir = "app/src/main/java/com/example", + layoutDir = "app/src/main/res/layout", + manifest = "app/src/main/AndroidManifest.xml", + ) +} diff --git a/plugins/AI-Core/src/test/kotlin/com/itsaky/androidide/plugins/aicore/viewmodel/ProjectLayoutTest.kt b/plugins/AI-Core/src/test/kotlin/com/itsaky/androidide/plugins/aicore/prompt/ProjectLayoutTest.kt similarity index 98% rename from plugins/AI-Core/src/test/kotlin/com/itsaky/androidide/plugins/aicore/viewmodel/ProjectLayoutTest.kt rename to plugins/AI-Core/src/test/kotlin/com/itsaky/androidide/plugins/aicore/prompt/ProjectLayoutTest.kt index b1a2dcb3..784c5406 100644 --- a/plugins/AI-Core/src/test/kotlin/com/itsaky/androidide/plugins/aicore/viewmodel/ProjectLayoutTest.kt +++ b/plugins/AI-Core/src/test/kotlin/com/itsaky/androidide/plugins/aicore/prompt/ProjectLayoutTest.kt @@ -1,4 +1,4 @@ -package com.itsaky.androidide.plugins.aicore.viewmodel +package com.itsaky.androidide.plugins.aicore.prompt import java.io.File import java.nio.file.Files diff --git a/plugins/AI-Core/src/test/kotlin/com/itsaky/androidide/plugins/aicore/prompt/PromptConfigChecksTest.kt b/plugins/AI-Core/src/test/kotlin/com/itsaky/androidide/plugins/aicore/prompt/PromptConfigChecksTest.kt new file mode 100644 index 00000000..62e9b268 --- /dev/null +++ b/plugins/AI-Core/src/test/kotlin/com/itsaky/androidide/plugins/aicore/prompt/PromptConfigChecksTest.kt @@ -0,0 +1,75 @@ +package com.itsaky.androidide.plugins.aicore.prompt + +import android.content.res.AssetManager +import com.itsaky.androidide.plugins.PluginContext +import com.itsaky.androidide.plugins.ai.prompt.AssetPromptConfigSource +import com.itsaky.androidide.plugins.ai.prompt.PromptConfigException +import com.itsaky.androidide.plugins.ai.prompt.PromptConfigLoader +import com.itsaky.androidide.plugins.aicore.prompt.config.AgentPromptConfigParser +import com.itsaky.androidide.plugins.aicore.prompt.config.DirectoryPromptConfigSource.Companion.shippedWith +import com.itsaky.androidide.plugins.aicore.tool.ToolHandler +import com.itsaky.androidide.plugins.aicore.tool.handlers.BuiltInToolHandlers +import io.mockk.every +import io.mockk.mockk +import java.io.File +import java.io.FileNotFoundException +import kotlinx.coroutines.runBlocking +import org.junit.Assert.assertEquals +import org.junit.Assert.assertThrows +import org.junit.Assert.assertTrue +import org.junit.Test + +/** + * The config as activation loads and checks it: through the plugin's assets, then every render + * check at once, so a shipped YAML edit that would break a chat turn fails here instead. + */ +class PromptConfigChecksTest { + + /** The catalogue activation checks against, not a copy of it. */ + private val builtIns: List = + BuiltInToolHandlers.create(mockk(relaxed = true)) + + /** The module's assets directory behind an [AssetManager], recording every path opened. */ + private fun shippedAssets(opened: MutableList = mutableListOf()): AssetManager = + mockk().also { assets -> + every { assets.open(any()) } answers { + val path = firstArg().also(opened::add) + val file = File("src/main/assets", path) + if (!file.isFile) throw FileNotFoundException(path) + file.inputStream() + } + } + + @Test + fun givenTheShippedAssets_whenLoadedAsActivationDoes_thenEveryCheckPasses() { + val opened = mutableListOf() + + val source = AssetPromptConfigSource(shippedAssets(opened)) + val config = runBlocking { PromptConfigLoader.load(source, AgentPromptConfigParser) } + + assertEquals(emptyList(), PromptConfigChecks.problems(config, builtIns)) + assertTrue("read outside prompts/: $opened", opened.isNotEmpty() && opened.all { it.startsWith("prompts/") }) + } + + @Test + fun givenAnIncludedAssetMissing_whenLoadedAsActivationDoes_thenTheLoadIsRefused() { + val assets = shippedAssets() + every { assets.open("prompts/rules.yml") } throws FileNotFoundException("prompts/rules.yml") + + val error = assertThrows(PromptConfigException::class.java) { + runBlocking { PromptConfigLoader.load(AssetPromptConfigSource(assets), AgentPromptConfigParser) } + } + + assertTrue(error.message.orEmpty(), error.message.orEmpty().contains("rules.yml")) + } + + @Test + fun givenATypoInALayout_whenChecked_thenActivationReportsItByFileAndPath() { + val config = shippedWith("layout.yml") { it.replace("{{DRAFT}}", "{{DRAFTT}}") } + + assertEquals( + listOf("layout.yml: layout.answer_review: unknown name {{DRAFTT}}"), + PromptConfigChecks.problems(config, builtIns), + ) + } +} diff --git a/plugins/AI-Core/src/test/kotlin/com/itsaky/androidide/plugins/aicore/prompt/PromptToolCatalogTest.kt b/plugins/AI-Core/src/test/kotlin/com/itsaky/androidide/plugins/aicore/prompt/PromptToolCatalogTest.kt new file mode 100644 index 00000000..c14fe74d --- /dev/null +++ b/plugins/AI-Core/src/test/kotlin/com/itsaky/androidide/plugins/aicore/prompt/PromptToolCatalogTest.kt @@ -0,0 +1,58 @@ +package com.itsaky.androidide.plugins.aicore.prompt + +import com.itsaky.androidide.plugins.aicore.prompt.config.DirectoryPromptConfigSource.Companion.shippedConfig +import com.itsaky.androidide.plugins.aicore.tool.AgentTools +import com.itsaky.androidide.plugins.aicore.tool.ToolApprovalManager +import com.itsaky.androidide.plugins.aicore.tool.sources.ToolSourceStore +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +/** Unit tests for [PromptToolCatalog], the one tool list both halves of the protocol are built from. */ +class PromptToolCatalogTest { + + private val tools = AgentTools.build( + builtInHandlers = emptyList(), + store = ToolSourceStore(), + approvalManager = ToolApprovalManager({ shippedConfig }), + terminalTool = "respond", + ) + + @Test + fun givenASnapshot_whenListingForThePrompt_thenTheTerminalToolIsAppended() { + // It is not a handler, so the snapshot does not carry it, but the model must be told it exists. + val names = PromptToolCatalog.definitions(tools, "respond", shippedConfig).map { it.name } + + assertEquals(listOf("respond"), names.takeLast(1)) + } + + @Test + fun givenASnapshot_whenListingForThePrompt_thenTheTerminalToolCarriesAMessageParameter() { + // A parameterless declaration is one a natively calling model cannot put the answer in, + // which is the empty respond the description warns about. + val respond = PromptToolCatalog.definitions(tools, "respond", shippedConfig).last() + + @Suppress("UNCHECKED_CAST") + val schema = respond.parametersSchema!! + val properties = schema["properties"] as Map + assertTrue(properties.containsKey("message")) + assertEquals(listOf("message"), schema["required"]) + } + + @Test + fun givenARenamedTerminalTool_whenListingForThePrompt_thenTheNewNameIsDeclared() { + val names = PromptToolCatalog.definitions(tools, "answer", shippedConfig).map { it.name } + + assertTrue(names.contains("answer")) + } + + @Test + fun givenARenamedTerminalTool_whenListingForThePrompt_thenItsDescriptionUsesThatName() { + // A description that still says "respond" names a tool the run does not offer. + val answer = PromptToolCatalog.definitions(tools, "answer", shippedConfig).last() + + assertTrue(answer.description.contains("calling answer with no")) + assertFalse(answer.description.contains("respond")) + } +} diff --git a/plugins/AI-Core/src/test/kotlin/com/itsaky/androidide/plugins/aicore/prompt/SessionContextTest.kt b/plugins/AI-Core/src/test/kotlin/com/itsaky/androidide/plugins/aicore/prompt/SessionContextTest.kt new file mode 100644 index 00000000..185007e4 --- /dev/null +++ b/plugins/AI-Core/src/test/kotlin/com/itsaky/androidide/plugins/aicore/prompt/SessionContextTest.kt @@ -0,0 +1,26 @@ +package com.itsaky.androidide.plugins.aicore.prompt + +import java.time.ZoneId +import java.time.ZonedDateTime +import org.junit.Assert.assertEquals +import org.junit.Test + +/** Unit tests for [SessionContext.current], the clock line every prompt carries. */ +class SessionContextTest { + + @Test + fun givenAZonedMoment_whenReadingTheSession_thenTheDayDateTimeZoneAndOffsetAreStated() { + val now = ZonedDateTime.of(2026, 9, 25, 14, 3, 0, 0, ZoneId.of("America/Mexico_City")) + + val session = SessionContext.current(now = now) + + assertEquals("Friday, 25 September 2026, 14:03 (America/Mexico_City, UTC-06:00)", session.currentTime) + } + + @Test + fun givenUtc_whenReadingTheSession_thenTheOffsetIsWrittenOutRatherThanZ() { + val now = ZonedDateTime.of(2029, 1, 1, 9, 0, 0, 0, ZoneId.of("UTC")) + + assertEquals("Monday, 1 January 2029, 09:00 (UTC, UTC+00:00)", SessionContext.current(now).currentTime) + } +} diff --git a/plugins/AI-Core/src/test/kotlin/com/itsaky/androidide/plugins/aicore/prompt/SystemPromptConfigTest.kt b/plugins/AI-Core/src/test/kotlin/com/itsaky/androidide/plugins/aicore/prompt/SystemPromptConfigTest.kt new file mode 100644 index 00000000..cf5cd5f3 --- /dev/null +++ b/plugins/AI-Core/src/test/kotlin/com/itsaky/androidide/plugins/aicore/prompt/SystemPromptConfigTest.kt @@ -0,0 +1,173 @@ +package com.itsaky.androidide.plugins.aicore.prompt + +import com.itsaky.androidide.plugins.aicore.prompt.config.AgentPromptConfig +import com.itsaky.androidide.plugins.aicore.prompt.config.DirectoryPromptConfigSource.Companion.shippedConfig +import com.itsaky.androidide.plugins.aicore.prompt.config.DirectoryPromptConfigSource.Companion.shippedWith +import com.itsaky.androidide.plugins.services.LlmInferenceService.SystemPromptRequest +import com.itsaky.androidide.plugins.services.LlmInferenceService.ToolDefinition +import kotlinx.coroutines.runBlocking +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +/** + * Unit tests for the behaviour/integration split: the agent's wording, tone, rules and layout change + * by editing the `assets/prompts/` files alone, and a typo is caught, by file, before a turn hits it. + */ +class SystemPromptConfigTest { + + private val tools = listOf(ToolDefinition("read_file", "Read a file.", emptyMap())) + + /** A backend with no prompt of its own, so the general prompt is what gets sent. */ + private val noPrompt = object : BackendPrompts { + override fun callsToolsNatively(): Boolean = false + override fun systemPrompt(request: SystemPromptRequest): String? = null + } + + /** A backend with its own prompt, so only the IDE CONTEXT block is added. */ + private val ownPrompt = object : BackendPrompts { + override fun callsToolsNatively(): Boolean = true + override fun systemPrompt(request: SystemPromptRequest): String = "I am Gemini." + } + + @Test + fun givenTheShippedConfig_whenChecked_thenEveryLayoutRenders() { + // A typo fails every chat turn, so the shipped file must render with every section open. + assertEquals(emptyList(), SystemPromptRenderer.problems(shippedConfig)) + } + + @Test + fun givenANewRule_whenCreating_thenItIsSentUnderItsPriority() { + val config = shippedWith("rules.yml") { + it.replace( + " - After a tool call, stop and wait", + " - NEW RULE for {{TERMINAL_TOOL}}.\n - After a tool call, stop and wait", + ) + } + + val prompt = create(config, noPrompt) + + assertTrue(prompt.contains("IMPORTANT:\n- NEW RULE for respond.\n- After a tool call")) + } + + @Test + fun givenANewPriority_whenCreating_thenItIsSentAsItsOwnGroupAfterTheOthers() { + val config = shippedWith("rules.yml") { it + " - heading: OPTIONAL\n items:\n - Be brief.\n" } + + val prompt = create(config, noPrompt) + + assertTrue(prompt.contains("\"respond\".\n\nOPTIONAL:\n- Be brief.\n\nTools:")) + } + + @Test + fun givenANewIdentity_whenCreating_thenTheToneChangesWithNoCodeChange() { + val config = shippedWith("agent.yml") { + it.replace(Regex("(?s)identity: >-\n.*?\n\n"), "identity: Eres un asistente de programación.\n\n") + } + + val prompt = create(config, noPrompt) + + assertTrue(prompt.startsWith("Eres un asistente de programación.\n\nCRITICAL:")) + } + + @Test + fun givenATranslatedHeading_whenCreating_thenTheTranslationIsSent() { + // Language support is an edit to the file: every word the model reads comes from it. + val config = shippedWith("tools.yml") { it.replace(" heading: Tools", " heading: Herramientas") } + + val prompt = create(config, noPrompt) + + assertTrue(prompt.contains("Herramientas:\n- read_file: Read a file.")) + } + + @Test + fun givenAReorderedLayout_whenCreating_thenTheSectionsFollowIt() { + val config = shippedWith("layout.yml") { + it.replace(" {{IDENTITY}}\n", " {{TOOLS_HEADING}}!\n {{IDENTITY}}\n") + } + + val prompt = create(config, noPrompt) + + assertTrue(prompt.startsWith("Tools!\nYou are a coding assistant")) + } + + @Test + fun givenANewRule_whenTheBackendHasItsOwnPrompt_thenItIsNotSent() { + // The general prompt is the fallback only; the backend's wording is not mixed with it. + val config = shippedWith("rules.yml") { + it.replace(" - After a tool call", " - NEW RULE.\n - After a tool call") + } + + val prompt = create(config, ownPrompt) + + assertTrue(prompt.startsWith("I am Gemini.\n\n")) + assertFalse(prompt.contains("NEW RULE.")) + } + + @Test + fun givenAnIdeContext_whenTheBackendHasItsOwnPrompt_thenTheContextBlockIsAppended() { + val context = IdeContext("app/Main.kt", emptyList(), emptyList()) + + val prompt = create(shippedConfig, ownPrompt, context) + + assertTrue(prompt.startsWith("I am Gemini.\n\nCurrent date and time")) + assertTrue(prompt.contains("\n\nIDE CONTEXT")) + } + + @Test + fun givenAnIdeContext_whenTheGeneralPromptIsUsed_thenItEndsWithTheContextBlock() { + val context = IdeContext("app/Main.kt", emptyList(), emptyList()) + + val prompt = create(shippedConfig, noPrompt, context) + + assertTrue(prompt.contains("\n\nCurrent date and time")) + assertTrue(prompt.contains("\n\nIDE CONTEXT")) + assertTrue(prompt.endsWith("do not guess a different folder or extension.")) + } + + @Test + fun givenARuleWithATypo_whenChecked_thenItIsReportedByItsPathAndName() { + val config = shippedWith("rules.yml") { + it.replace("use \"{{TERMINAL_TOOL}}\".", "use \"{{TERMINAL_TOLL}}\".") + } + + val problems = SystemPromptRenderer.problems(config) + + assertEquals(listOf("rules.yml: rules[2].items[2]: unknown name {{TERMINAL_TOLL}}"), problems) + } + + @Test + fun givenATypoInASectionThatIsUsuallyClosed_whenChecked_thenItIsStillReported() { + // The check opens every section, so a branch a run rarely takes is still covered. + val config = shippedWith("ide_context.yml") { it.replace("{{LAYOUT_DIR}}", "{{LAYOUTDIR}}") } + + val problems = SystemPromptRenderer.problems(config) + + assertEquals(listOf("ide_context.yml: ide_context.module_layout_dir: unknown name {{LAYOUTDIR}}"), problems) + } + + @Test + fun givenATypoBehindAnInvertedSection_whenChecked_thenItIsStillReported() { + val config = shippedWith("layout.yml") { + it.replace(" {{^FIRST}}\n\n", " {{^FIRST}}\n {{SEPARATR}}\n") + } + + val problems = SystemPromptRenderer.problems(config) + + assertEquals(listOf("layout.yml: layout.system_prompt: unknown name {{SEPARATR}}"), problems) + } + + private fun create( + config: AgentPromptConfig, + backend: BackendPrompts, + context: IdeContext = IdeContext.EMPTY, + ): String = runBlocking { + SystemPromptFactory({ config }, { context }, backend, { SESSION }, "respond", SYNTAX).create(tools) + } + + private companion object { + val SESSION = SessionContext("Monday, 1 January 2029, 09:00 (UTC, UTC+00:00)") + const val SYNTAX = """{"tool":"TOOL_NAME","args":{"arg":"value"}}""" + } +} diff --git a/plugins/AI-Core/src/test/kotlin/com/itsaky/androidide/plugins/aicore/prompt/SystemPromptFactoryTest.kt b/plugins/AI-Core/src/test/kotlin/com/itsaky/androidide/plugins/aicore/prompt/SystemPromptFactoryTest.kt new file mode 100644 index 00000000..d243f575 --- /dev/null +++ b/plugins/AI-Core/src/test/kotlin/com/itsaky/androidide/plugins/aicore/prompt/SystemPromptFactoryTest.kt @@ -0,0 +1,139 @@ +package com.itsaky.androidide.plugins.aicore.prompt + +import com.itsaky.androidide.plugins.aicore.prompt.config.DirectoryPromptConfigSource.Companion.shippedConfig +import com.itsaky.androidide.plugins.services.LlmInferenceService.SystemPromptRequest +import com.itsaky.androidide.plugins.services.LlmInferenceService.ToolDefinition +import kotlinx.coroutines.runBlocking +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test + +/** + * Unit tests for [SystemPromptFactory]. It assembles a prompt out of collaborators it is handed, + * so a fake backend and a fake IDE cover the whole order — no device, no network, no model. + */ +class SystemPromptFactoryTest { + + private val tools = listOf(ToolDefinition("read_file", "Read a file.", emptyMap())) + + /** A backend that answers with [prompt] and records what it was asked. */ + private class FakeBackend( + private val prompt: String?, + private val native: Boolean = false, + ) : BackendPrompts { + var request: SystemPromptRequest? = null + override fun callsToolsNatively(): Boolean = native + override fun systemPrompt(request: SystemPromptRequest): String? { + this.request = request + return prompt + } + } + + private fun factory( + backend: BackendPrompts, + context: IdeContext = IdeContext.EMPTY, + session: SessionContext = SESSION, + ) = + SystemPromptFactory( + config = { shippedConfig }, + ideContext = { context }, + backend = backend, + session = { session }, + terminalTool = "respond", + toolCallSyntax = SYNTAX, + ) + + @Test + fun givenABackendWithItsOwnPrompt_whenCreating_thenThatWordingIsUsed() { + // The backend knows its own model; this side only supplies the contract around it. + val prompt = runBlocking { factory(FakeBackend("I am Gemini.")).create(tools) } + + assertTrue(prompt.startsWith("I am Gemini.\n\n")) + } + + @Test + fun givenABackendWithItsOwnPrompt_whenCreating_thenTheDeviceTimeIsAppended() { + // Every backend, its own prompt or not, is told the date: none of them knows it. + val prompt = runBlocking { factory(FakeBackend("I am Gemini.")).create(tools) } + + assertTrue(prompt.contains("Current date and time on the user's device: ${SESSION.currentTime}")) + } + + @Test + fun givenABackendWithItsOwnPrompt_whenCreating_thenItIsToldTheWebIsReachable() { + val prompt = runBlocking { factory(FakeBackend("I am Gemini.")).create(tools) } + + assertTrue(prompt.contains("You can reach the internet.")) + } + + @Test + fun givenABackendWithNoPromptOfItsOwn_whenCreating_thenTheDefaultCarriesTheDeviceTime() { + val prompt = runBlocking { factory(FakeBackend(null)).create(tools) } + + assertTrue(prompt.contains("Current date and time on the user's device: ${SESSION.currentTime}")) + } + + @Test + fun givenABackendWithNoPromptOfItsOwn_whenCreating_thenTheDefaultIsUsed() { + // A third-party `.cgp` must work without shipping prompt text. + val prompt = runBlocking { factory(FakeBackend(null)).create(tools) } + + assertTrue(prompt.contains("You are a coding assistant inside CodeOnTheGo.")) + } + + @Test + fun givenATextProtocolBackend_whenCreating_thenItIsAskedForTheEnvelopeToTeach() { + val backend = FakeBackend("prompt", native = false) + + runBlocking { factory(backend).create(tools) } + + assertEquals(SYNTAX, backend.request?.toolCallSyntax) + } + + @Test + fun givenANativelyCallingBackend_whenCreating_thenItIsToldNoEnvelopeIsParsed() { + // Null is how SystemPromptRequest says "the provider carries the call, not the text". + val backend = FakeBackend("prompt", native = true) + + runBlocking { factory(backend).create(tools) } + + assertNull(backend.request?.toolCallSyntax) + } + + @Test + fun givenAnOpenFile_whenCreating_thenTheBackendIsGivenItAsTheExamplePath() { + val backend = FakeBackend("prompt") + val context = IdeContext("app/Main.kt", emptyList(), emptyList()) + + runBlocking { factory(backend, context).create(tools) } + + assertEquals("app/Main.kt", backend.request?.exampleFilePath) + } + + @Test + fun givenAnyBackend_whenCreating_thenTheRunsToolsAreTheOnesDescribed() { + // The same list the natively calling backend is sent, passed through unchanged. + val backend = FakeBackend("prompt") + + runBlocking { factory(backend).create(tools) } + + assertEquals(tools, backend.request?.tools) + } + + @Test + fun givenAnOpenFile_whenCreating_thenTheContextBlockIsAppendedToTheBackendsPrompt() { + val context = IdeContext("app/Main.kt", emptyList(), emptyList()) + + val prompt = runBlocking { factory(FakeBackend("I am Gemini."), context).create(tools) } + + assertTrue(prompt.startsWith("I am Gemini.\n\nCurrent date and time")) + assertTrue(prompt.contains("\n\nIDE CONTEXT")) + assertTrue(prompt.contains("- File the user is viewing: app/Main.kt")) + } + + private companion object { + val SESSION = SessionContext("Monday, 1 January 2029, 09:00 (UTC, UTC+00:00)") + const val SYNTAX = """{"tool":"TOOL_NAME","args":{"arg":"value"}}""" + } +} diff --git a/plugins/AI-Core/src/test/kotlin/com/itsaky/androidide/plugins/aicore/prompt/ToolDescriptionsTest.kt b/plugins/AI-Core/src/test/kotlin/com/itsaky/androidide/plugins/aicore/prompt/ToolDescriptionsTest.kt new file mode 100644 index 00000000..779db4d9 --- /dev/null +++ b/plugins/AI-Core/src/test/kotlin/com/itsaky/androidide/plugins/aicore/prompt/ToolDescriptionsTest.kt @@ -0,0 +1,188 @@ +package com.itsaky.androidide.plugins.aicore.prompt + +import com.itsaky.androidide.plugins.PluginContext +import com.itsaky.androidide.plugins.aicore.prompt.config.AgentPromptConfig +import com.itsaky.androidide.plugins.aicore.prompt.config.DirectoryPromptConfigSource.Companion.shippedConfig +import com.itsaky.androidide.plugins.aicore.prompt.config.DirectoryPromptConfigSource.Companion.shippedWith +import com.itsaky.androidide.plugins.aicore.tool.AgentTools +import com.itsaky.androidide.plugins.aicore.tool.ToolApprovalManager +import com.itsaky.androidide.plugins.aicore.tool.ToolHandler +import com.itsaky.androidide.plugins.aicore.tool.handlers.BuiltInToolHandlers +import com.itsaky.androidide.plugins.aicore.tool.sources.ToolSourceStore +import com.itsaky.androidide.plugins.services.LlmInferenceService.ToolDefinition +import io.mockk.mockk +import org.junit.Assert.assertEquals +import org.junit.Assert.assertSame +import org.junit.Assert.assertThrows +import org.junit.Assert.assertTrue +import org.junit.Test + +/** + * Unit tests for [ToolDescriptions]: what each built-in tool is for, as the model and the approval + * dialog read it, is `tool_descriptions.yml`'s, so rewording a tool is a config edit. + */ +class ToolDescriptionsTest { + + /** The catalogue the chat registers, not a copy of it, so a new built-in is checked too. */ + private val builtIns: List = + BuiltInToolHandlers.create(mockk(relaxed = true)) + + private val tools = + AgentTools.build(builtIns, ToolSourceStore(), ToolApprovalManager({ shippedConfig }), terminalTool = "respond") + + @Test + fun givenTheShippedConfig_whenChecked_thenEveryBuiltInAndEveryArgumentIsDescribed() { + assertEquals(emptyList(), ToolDescriptions.problems(shippedConfig, builtIns)) + } + + @Test + fun givenTheBuiltInHandlers_whenRead_thenNoneCarriesWordingOfItsOwn() { + // The wording is the config's; a handler that kept its own would silently be overridden. + for (handler in builtIns) { + assertEquals("${handler.toolName} description", "", handler.description) + argumentsOf(handler.parametersSchema).forEach { (name, property) -> + assertTrue("${handler.toolName}.$name", "description" !in property) + } + } + } + + @Test + fun givenTheShippedConfig_whenListingTools_thenEachDefinitionCarriesItsDescriptionAndArguments() { + val readFile = definitions().first { it.name == "read_file" } + + assertEquals("Read the contents of a file", readFile.description) + assertEquals( + mapOf("type" to "string", "description" to "Project-relative path of the file to read."), + argumentsOf(readFile.parametersSchema)["file_path"], + ) + assertEquals(listOf("file_path"), readFile.parametersSchema!!["required"]) + } + + @Test + fun givenNewWording_whenListingTools_thenItIsSentWithNoCodeChange() { + val config = shippedWith("tool_descriptions.yml") { + it.replace(" description: Read the contents of a file", " description: Lee el contenido de un archivo") + .replace("file_path: Project-relative path of the file to read.", "file_path: Ruta del archivo.") + } + + val readFile = definitions(config).first { it.name == "read_file" } + + assertEquals("Lee el contenido de un archivo", readFile.description) + assertEquals("Ruta del archivo.", argumentsOf(readFile.parametersSchema)["file_path"]!!["description"]) + } + + @Test + fun givenARenamedTerminalTool_whenListingTools_thenItsDescriptionNamesIt() { + val answer = PromptToolCatalog.definitions(tools, "answer", shippedConfig).last() + + assertEquals("answer", answer.name) + assertTrue(answer.description.contains("calling answer with no \"message\"")) + } + + @Test + fun givenABuiltInHandler_whenApproving_thenTheDialogShowsTheDescriptionTheModelReads() { + val runApp = builtIns.first { it.toolName == "run_app" } + + val shown = ToolDescriptions.describe(shippedConfig, "respond", runApp) + + assertEquals(definitions().first { it.name == "run_app" }.description, shown) + } + + @Test + fun givenAContributedDefinition_whenApplying_thenItPassesThroughUntouched() { + // Another plugin's tool brings its own wording; the config never rewrites it. + val contributed = ToolDefinition("mcp_files_search", "Search a remote index.", emptyMap()) + + val out = ToolDescriptions.apply(shippedConfig, "respond", listOf(contributed), setOf("read_file")) + + assertSame(contributed, out.single()) + } + + @Test + fun givenABuiltInMissingFromTheConfig_whenListingTools_thenTheFailureNamesTheFileAndTool() { + val config = shippedWith("tool_descriptions.yml") { + it.replace(Regex("(?s) gradle_sync:\n.*?\n(?= generate_from_template:)"), "") + } + + val error = assertThrows(IllegalArgumentException::class.java) { definitions(config) } + + assertEquals( + "tool_descriptions.yml: built_in_tools.gradle_sync is missing; every built-in tool needs a description", + error.message, + ) + } + + @Test + fun givenAnUndescribedArgument_whenChecked_thenItIsNamed() { + val config = shippedWith("tool_descriptions.yml") { + it.replace(" content: The file's full contents.\n", "") + } + + assertEquals( + listOf("tool_descriptions.yml: built_in_tools.create_file.arguments.content is missing"), + ToolDescriptions.problems(config, builtIns), + ) + } + + @Test + fun givenAMisspelledArgument_whenChecked_thenTheRealArgumentIsReportedMissing() { + val config = shippedWith("tool_descriptions.yml") { + it.replace(" replace_all: Replace", " replace_al: Replace") + } + + val problems = ToolDescriptions.problems(config, builtIns) + + assertEquals( + listOf("tool_descriptions.yml: built_in_tools.edit_file.arguments.replace_all is missing"), + problems, + ) + } + + @Test + fun givenAnArgumentTheToolDoesNotTake_whenChecked_thenItIsReported() { + // Describing an argument the code never declares teaches the model one it cannot pass. + val config = shippedWith("tool_descriptions.yml") { + it.replace( + " file_path: Project-relative path of the file to read.\n", + " file_path: Project-relative path of the file to read.\n encoding: Text encoding.\n", + ) + } + + assertEquals( + listOf("tool_descriptions.yml: built_in_tools.read_file.arguments.encoding: read_file takes no argument encoding"), + ToolDescriptions.problems(config, builtIns), + ) + } + + @Test + fun givenAToolNoBuiltInHas_whenChecked_thenTheStrayEntryIsReported() { + // A misspelled tool name would otherwise describe nothing while the real tool goes unworded. + val config = shippedWith("tool_descriptions.yml") { + it + " delete_file:\n description: Delete a file\n" + } + + assertEquals( + listOf("tool_descriptions.yml: built_in_tools.delete_file: no built-in tool is named delete_file"), + ToolDescriptions.problems(config, builtIns), + ) + } + + @Test + fun givenATypoInADescription_whenChecked_thenItIsReportedByItsPath() { + val config = shippedWith("tool_descriptions.yml") { + it.replace("calling {{TERMINAL_TOOL}}", "calling {{TERMINAL_TOLL}}") + } + + assertEquals( + listOf("tool_descriptions.yml: terminal_tool.description: unknown name {{TERMINAL_TOLL}}"), + ToolDescriptions.problems(config, builtIns), + ) + } + + private fun definitions(config: AgentPromptConfig = shippedConfig) = + PromptToolCatalog.definitions(tools, "respond", config) + + @Suppress("UNCHECKED_CAST") + private fun argumentsOf(schema: Map?): Map> = + (schema?.get("properties") as? Map>).orEmpty() +} diff --git a/plugins/AI-Core/src/test/kotlin/com/itsaky/androidide/plugins/aicore/prompt/ToolResultsPromptTest.kt b/plugins/AI-Core/src/test/kotlin/com/itsaky/androidide/plugins/aicore/prompt/ToolResultsPromptTest.kt new file mode 100644 index 00000000..68e59f68 --- /dev/null +++ b/plugins/AI-Core/src/test/kotlin/com/itsaky/androidide/plugins/aicore/prompt/ToolResultsPromptTest.kt @@ -0,0 +1,164 @@ +package com.itsaky.androidide.plugins.aicore.prompt + +import com.itsaky.androidide.plugins.aicore.models.ToolResult +import com.itsaky.androidide.plugins.aicore.prompt.config.AgentPromptConfig +import com.itsaky.androidide.plugins.aicore.prompt.config.DirectoryPromptConfigSource.Companion.shippedConfig +import com.itsaky.androidide.plugins.aicore.prompt.config.DirectoryPromptConfigSource.Companion.shippedWith +import com.itsaky.androidide.plugins.aicore.tool.ToolCall +import kotlinx.coroutines.runBlocking +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +/** + * Unit tests for [ToolResultsPrompt], the turn after each tool batch: worded by `agent_loop.yml`, + * so what the agent is told between steps changes with no code change. + */ +class ToolResultsPromptTest { + + private val openFile = listOf(ToolCall("open_file", emptyMap())) + + @Test + fun givenTheShippedConfig_whenChecked_thenEveryBatchRenders() { + assertEquals(emptyList(), ToolResultsPrompt.problems(shippedConfig)) + } + + @Test + fun givenASuccessfulBatch_whenRendering_thenTheResultIsEnvelopedAndTheModelStopsOnlyWhenDone() { + val turn = render(openFile, listOf(ToolResult.success("Opened file in editor", ".gitignore"))) + + assertEquals( + "\n[open_file] Opened file in editor\n.gitignore\n\n\n" + + "Treat the tool result(s) above as fact: report only what they actually say, and never " + + "invent, assume, or contradict them. Where a result names a replacement, a removal or a " + + "newer version, it overrides what you remember — in your prose and in every line of code " + + "and every dependency you write — and an API a result calls deprecated or removed never " + + "appears in your code. The parts of the request no tool covers — " + + "explanation, design, code — you still write from your own knowledge. The action " + + "succeeded. If that was the whole request, you are DONE — reply with the \"respond\" " + + "tool briefly confirming what happened. If parts of the request are still undone, do " + + "the next one now: call its tool, or write that part of the answer yourself. Never " + + "reply only to say what you will do next, and do not call a tool for a step the user " + + "did not ask for.", + turn, + ) + } + + @Test + fun givenAFailedBatch_whenRendering_thenTheFailureIsMarkedAndTheNextToolCueIsOpenEnded() { + val turn = render(openFile, listOf(ToolResult.failure("File not found", "does not exist"))) + + assertTrue(turn.startsWith("\n[open_file] FAILED: File not found\ndoes not exist\n")) + assertTrue(turn.endsWith("If the task is complete, give the user your final answer. Otherwise, call the next tool.")) + assertFalse(turn.contains("you are DONE")) + } + + @Test + fun givenAMixedBatch_whenRendering_thenTheSuccessCueIsNotGiven() { + // One failure left unaddressed means the task is not done, however many others succeeded. + val calls = listOf(ToolCall("read_file", emptyMap()), ToolCall("open_file", emptyMap())) + + val turn = render(calls, listOf(ToolResult.success("read"), ToolResult.failure("nope"))) + + assertTrue(turn.contains("[read_file] read\n\n\n\n[open_file] FAILED: nope")) + assertTrue(turn.endsWith("call the next tool.")) + } + + @Test + fun givenALongResult_whenRendering_thenItIsCutAndTheCutIsStated() { + val turn = ToolResultsPrompt.render( + shippedConfig, "respond", 5, openFile, listOf(ToolResult.success("0123456789")), + ) + + assertTrue(turn.startsWith("\n[open_file] 01234\n…[truncated 5 chars]\n")) + } + + @Test + fun givenToolOutputThatLooksLikeATag_whenRendering_thenItReachesTheModelVerbatim() { + // A file's contents are data: a `{{X}}` in them must not be read as a template tag. + val turn = render(openFile, listOf(ToolResult.failure("{{KEPT}} {{#X}}"))) + + assertTrue(turn.contains("FAILED: {{KEPT}} {{#X}}")) + } + + @Test + fun givenARenamedTerminalTool_whenRendering_thenTheSuccessCueNamesIt() { + // The shipped text used to hardcode "respond", teaching a tool a renamed run lacks. + val turn = ToolResultsPrompt.render( + shippedConfig, "answer", 4000, openFile, listOf(ToolResult.success("ok")), + ) + + assertTrue(turn.contains("reply with the \"answer\" tool")) + } + + @Test + fun givenNewWordingInAgentLoopYml_whenRendering_thenItIsSentWithNoCodeChange() { + val config = shippedWith("agent_loop.yml") { + it.replace("failed: \"FAILED: {{MESSAGE}}\"", "failed: \"ERROR — {{MESSAGE}}\"") + .replace(Regex(" after_failure: [^\n]*"), " after_failure: Intenta con otra herramienta.") + } + + val turn = render(openFile, listOf(ToolResult.failure("nope")), config) + + assertTrue(turn.contains("[open_file] ERROR — nope\n")) + assertTrue(turn.endsWith("Intenta con otra herramienta.")) + } + + @Test + fun givenATypoInAgentLoopYml_whenChecked_thenItIsReportedByItsFileAndPath() { + val config = shippedWith("agent_loop.yml") { it.replace("{{COUNT}}", "{{CUONT}}") } + + val problems = ToolResultsPrompt.problems(config) + + assertEquals(listOf("agent_loop.yml: agent_loop.truncated: unknown name {{CUONT}}"), problems) + } + + @Test + fun givenAProvider_whenFormatting_thenTheCachedConfigIsWhatWordsTheTurn() { + val prompt = ToolResultsPrompt({ shippedConfig }, "respond") + + val turn = runBlocking { prompt.format(openFile, listOf(ToolResult.success("ok"))) } + + assertEquals(render(openFile, listOf(ToolResult.success("ok"))), turn) + } + + private fun render( + calls: List, + results: List, + config: AgentPromptConfig = shippedConfig, + ): String = ToolResultsPrompt.render(config, "respond", ToolResultsPrompt.DEFAULT_CHAR_LIMIT, calls, results) + + @Test + fun givenTheShippedConfig_whenRenderingTheUnfinishedTurn_thenItNamesTheTerminalTool() { + val turn = ToolResultsPrompt.renderUnfinished(shippedConfig, "answer") + + assertTrue(turn.contains("call the \"answer\" tool")) + assertFalse(turn.contains("{{")) + } + + @Test + fun givenTheShippedConfig_whenRenderingTheRequiredToolTurn_thenItNamesTheToolAndNoTemplateIsLeft() { + val turn = ToolResultsPrompt.renderRequiredTool(shippedConfig, "respond", "web_search") + + assertTrue(turn.contains("did not call \"web_search\"")) + assertTrue(turn.contains("Call \"web_search\" now")) + assertFalse(turn.contains("{{")) + } + + @Test + fun givenASearchReportPastTheDefaultCap_whenRendering_thenItsSourcesAreNotCut() { + val report = "x".repeat(ToolResultsPrompt.DEFAULT_CHAR_LIMIT) + "\n\nSources:\n- https://ktor.io/docs" + + val turn = render(listOf(ToolCall("web_search", emptyMap())), listOf(ToolResult.success("Searched", report))) + + assertTrue(turn.contains("- https://ktor.io/docs\n")) + } + + @Test + fun givenAProjectResultPastTheDefaultCap_whenRendering_thenItIsStillCut() { + val turn = render(openFile, listOf(ToolResult.success("x".repeat(ToolResultsPrompt.DEFAULT_CHAR_LIMIT + 10)))) + + assertTrue(turn.contains("[truncated")) + } +} diff --git a/plugins/AI-Core/src/test/kotlin/com/itsaky/androidide/plugins/aicore/prompt/config/AgentPromptConfigParserTest.kt b/plugins/AI-Core/src/test/kotlin/com/itsaky/androidide/plugins/aicore/prompt/config/AgentPromptConfigParserTest.kt new file mode 100644 index 00000000..480bc852 --- /dev/null +++ b/plugins/AI-Core/src/test/kotlin/com/itsaky/androidide/plugins/aicore/prompt/config/AgentPromptConfigParserTest.kt @@ -0,0 +1,111 @@ +package com.itsaky.androidide.plugins.aicore.prompt.config + +import com.itsaky.androidide.plugins.ai.prompt.PromptConfigException +import com.itsaky.androidide.plugins.aicore.prompt.config.DirectoryPromptConfigSource.Companion.shippedConfig +import com.itsaky.androidide.plugins.aicore.prompt.config.DirectoryPromptConfigSource.Companion.shippedWith +import org.junit.Assert.assertEquals +import org.junit.Assert.assertThrows +import org.junit.Assert.assertTrue +import org.junit.Test + +/** + * Unit tests for [AgentPromptConfigParser]: a mistake in a prompt file is refused naming that file + * and the key, rather than reaching the model as a prompt with a hole in it. + */ +class AgentPromptConfigParserTest { + + @Test + fun givenTheShippedFiles_whenParsing_thenEveryTextIsLabelledWithItsOwnFileAndPath() { + assertEquals("agent.yml: identity", shippedConfig.identity.label) + assertEquals("rules.yml: rules[0].items[1]", shippedConfig.rules[0].items[1].label) + assertEquals("tools.yml: tool_call_format.example", shippedConfig.toolCallFormat.example.label) + assertEquals("layout.yml: layout.system_prompt", shippedConfig.layout.systemPrompt.label) + } + + @Test + fun givenABlockScalar_whenParsing_thenItsTrailingNewlineIsDropped() { + assertTrue(shippedConfig.layout.systemPrompt.template.endsWith("{{LAYOUT_IDE_CONTEXT}}")) + } + + @Test + fun givenAFoldedScalar_whenParsing_thenItsLinesAreJoinedIntoOneSentence() { + // Source line wraps must not reach the model as newlines mid-sentence. + assertTrue(shippedConfig.identity.template.contains("not only Android questions.")) + assertTrue('\n' !in shippedConfig.identity.template) + } + + @Test + fun givenAMissingNestedKey_whenParsing_thenItIsNamedWithItsFileAndPath() { + assertRefused("tools.yml: tool_call_format.example_heading is missing", "tools.yml") { + it.replace(Regex("(?m)^ example_heading: .*\n"), "") + } + } + + @Test + fun givenAMissingTopLevelKey_whenParsing_thenItIsReportedAgainstTheEntryFile() { + // No file holds it, so the entry file, which decides what is read, is the one to fix. + assertRefused("agent.yml: rules is missing", "rules.yml") { "other: x\n" } + } + + @Test + fun givenAMisspelledKey_whenParsing_thenTheRealKeyIsReportedMissing() { + val error = refused("rules.yml") { it.replace(" items:\n - Reply", " itmes:\n - Reply") } + + assertTrue(error.message!!.startsWith("rules.yml: rules[0].items is missing")) + } + + @Test + fun givenAnExtraNestedKey_whenParsing_thenItIsRefusedAsUnknown() { + assertRefused("tools.yml: tools: unknown key tone; expected heading", "tools.yml") { + it.replace("tools:\n heading: Tools", "tools:\n heading: Tools\n tone: friendly") + } + } + + @Test + fun givenAnExtraTopLevelKey_whenParsing_thenTheFileHoldingItIsNamed() { + val error = refused("layout.yml") { "$it\ntone: friendly\n" } + + assertTrue(error.message!!.startsWith("layout.yml: unknown key tone; expected ")) + } + + @Test + fun givenAnUnquotedNumber_whenParsing_thenItIsRefusedAsNotText() { + assertRefused("tools.yml: tools.heading expected text; quote it", "tools.yml") { + it.replace(" heading: Tools", " heading: 42") + } + } + + @Test + fun givenAPriorityWithNoRules_whenParsing_thenItIsRefused() { + assertRefused("rules.yml: rules[1].items is empty", "rules.yml") { + it.replace(Regex("(?s)(- heading: IMPORTANT\n items:).*?(\n - heading)"), "$1 []$2") + } + } + + @Test + fun givenANewerSchemaVersion_whenParsing_thenItIsRefusedNamingBoth() { + assertRefused("agent.yml: schema_version is 3, but this ai-core reads 2", "agent.yml") { + it.replace("schema_version: 2", "schema_version: 3") + } + } + + @Test + fun givenBrokenYaml_whenParsing_thenTheFileNameAndPositionAreReported() { + val error = refused("rules.yml") { "rules: [unclosed" } + + assertTrue(error.message!!.startsWith("rules.yml: ")) + assertTrue(error.message!!.contains("line")) + } + + @Test + fun givenADuplicateKeyInOneFile_whenParsing_thenItIsRefused() { + // YAML would otherwise keep the second silently, and an edit to the first would do nothing. + refused("agent.yml") { "$it\nidentity: again\n" } + } + + private fun refused(file: String, edit: (String) -> String): PromptConfigException = + assertThrows(PromptConfigException::class.java) { shippedWith(file, edit) } + + private fun assertRefused(message: String, file: String, edit: (String) -> String) = + assertEquals(message, refused(file, edit).message) +} diff --git a/plugins/AI-Core/src/test/kotlin/com/itsaky/androidide/plugins/aicore/prompt/config/DirectoryPromptConfigSource.kt b/plugins/AI-Core/src/test/kotlin/com/itsaky/androidide/plugins/aicore/prompt/config/DirectoryPromptConfigSource.kt new file mode 100644 index 00000000..1049e920 --- /dev/null +++ b/plugins/AI-Core/src/test/kotlin/com/itsaky/androidide/plugins/aicore/prompt/config/DirectoryPromptConfigSource.kt @@ -0,0 +1,46 @@ +package com.itsaky.androidide.plugins.aicore.prompt.config + +import com.itsaky.androidide.plugins.ai.prompt.PromptConfigLoader +import com.itsaky.androidide.plugins.ai.prompt.PromptConfigSource +import java.io.File +import java.io.FileNotFoundException +import kotlinx.coroutines.runBlocking + +/** + * Reads config from a directory, so JVM tests render the exact files the `.cgp` ships. + * + * @param root the directory holding the config files. + * @param edits replaces one file's text before it is returned, as a device would see an edited file. + */ +class DirectoryPromptConfigSource( + private val root: File, + private val edits: Map String> = emptyMap(), +) : PromptConfigSource { + + override fun read(path: String): String { + val file = File(root, path) + if (!file.isFile) throw FileNotFoundException(path) + return edits[path]?.invoke(file.readText()) ?: file.readText() + } + + companion object { + /** The shipped config files; unit tests run with the module directory as working dir. */ + val SHIPPED_ROOT = File("src/main/assets/prompts") + + /** The shipped config, loaded once for every test that renders a prompt. */ + val shippedConfig: AgentPromptConfig by lazy { load(DirectoryPromptConfigSource(SHIPPED_ROOT)) } + + /** + * Loads the shipped config with one file rewritten by [edit]. + * + * @param file the file to edit, e.g. `rules.yml`. + * @param edit rewrites that file's text. + * @return the config loaded from the edited files. + */ + fun shippedWith(file: String, edit: (String) -> String): AgentPromptConfig = + load(DirectoryPromptConfigSource(SHIPPED_ROOT, mapOf(file to edit))) + + private fun load(source: PromptConfigSource): AgentPromptConfig = + runBlocking { PromptConfigLoader.load(source, AgentPromptConfigParser) } + } +} diff --git a/plugins/AI-Core/src/test/kotlin/com/itsaky/androidide/plugins/aicore/prompt/config/ShippedPromptFilesTest.kt b/plugins/AI-Core/src/test/kotlin/com/itsaky/androidide/plugins/aicore/prompt/config/ShippedPromptFilesTest.kt new file mode 100644 index 00000000..5052c948 --- /dev/null +++ b/plugins/AI-Core/src/test/kotlin/com/itsaky/androidide/plugins/aicore/prompt/config/ShippedPromptFilesTest.kt @@ -0,0 +1,55 @@ +package com.itsaky.androidide.plugins.aicore.prompt.config + +import com.itsaky.androidide.plugins.ai.prompt.PromptConfigLoader +import com.itsaky.androidide.plugins.ai.prompt.PromptConfigSource +import com.itsaky.androidide.plugins.aicore.prompt.config.DirectoryPromptConfigSource.Companion.SHIPPED_ROOT +import java.io.File +import kotlinx.coroutines.runBlocking +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +/** The shipped `assets/prompts/` files: every one included once, in order, with no malformed tag. */ +class ShippedPromptFilesTest { + + /** The shipped files, by name. */ + private val shipped: Map = + SHIPPED_ROOT.listFiles { f -> f.extension == "yml" }!!.associate { it.name to it.readText() } + + @Test + fun givenTheShippedEntryFile_whenLoading_thenItAndEveryIncludeAreReadInOrder() { + val paths = mutableListOf() + val source = PromptConfigSource { path -> paths += path; File(SHIPPED_ROOT, path).readText() } + + runBlocking { PromptConfigLoader.load(source, AgentPromptConfigParser) } + + assertEquals( + listOf("agent.yml", "rules.yml", "tools.yml", "ide_context.yml", "agent_loop.yml", "context_files.yml", "chat_title.yml", "tool_descriptions.yml", "web_search.yml", "answer_review.yml", "layout.yml"), + paths, + ) + } + + @Test + fun givenEveryShippedFile_whenListed_thenEachIsIncludedExactlyOnce() { + // A .yml nobody includes is dead wording that looks live to whoever edits it. + val entry = shipped.getValue("agent.yml") + val included = Regex("(?m)^ - (\\S+\\.yml)$").findAll(entry).map { it.groupValues[1] } + + assertEquals(shipped.keys - "agent.yml", included.toSet()) + } + + @Test + fun givenTheShippedFiles_whenScanned_thenNoTagIsMalformed() { + // A `{{name}}` or `{{ #X}}` typo would reach the model verbatim, since it is no tag. + assertTrue(shipped.isNotEmpty()) + for ((name, text) in shipped) { + assertFalse("$name has a malformed tag", MALFORMED_TAG.containsMatchIn(text)) + } + } + + private companion object { + /** A `{{` that opens none of `{{NAME}}`, `{{#NAME}}`, `{{^NAME}}` or `{{/NAME}}`. */ + val MALFORMED_TAG = Regex("""\{\{(?![#^/]?[A-Z])""") + } +} diff --git a/plugins/AI-Core/src/test/kotlin/com/itsaky/androidide/plugins/aicore/tool/AgentLoopTest.kt b/plugins/AI-Core/src/test/kotlin/com/itsaky/androidide/plugins/aicore/tool/AgentLoopTest.kt index 88644506..3b6eaf94 100644 --- a/plugins/AI-Core/src/test/kotlin/com/itsaky/androidide/plugins/aicore/tool/AgentLoopTest.kt +++ b/plugins/AI-Core/src/test/kotlin/com/itsaky/androidide/plugins/aicore/tool/AgentLoopTest.kt @@ -1,6 +1,8 @@ package com.itsaky.androidide.plugins.aicore.tool import com.itsaky.androidide.plugins.aicore.models.ToolResult +import com.itsaky.androidide.plugins.aicore.prompt.ToolResultsPrompt +import com.itsaky.androidide.plugins.aicore.prompt.config.DirectoryPromptConfigSource.Companion.shippedConfig import com.itsaky.androidide.plugins.services.LlmInferenceService.ChatMessage import com.itsaky.androidide.plugins.services.LlmInferenceService.ChatMessage.Role import kotlinx.coroutines.test.runTest @@ -27,6 +29,46 @@ class AgentLoopTest { } } + /** A loop worded by the shipped `agent_loop.yml`, as the ViewModel builds it. */ + private fun agentLoop( + maxIterations: Int = AgentLoop.DEFAULT_MAX_ITERATIONS, + maxConsecutiveRepeats: Int = AgentLoop.DEFAULT_MAX_CONSECUTIVE_REPEATS, + terminalTool: String? = null, + toolOutputCharLimit: Int = ToolResultsPrompt.DEFAULT_CHAR_LIMIT, + unfinishedTurn: (suspend () -> String)? = null, + ) = AgentLoop( + formatToolResults = ToolResultsPrompt({ shippedConfig }, "respond", toolOutputCharLimit), + maxIterations = maxIterations, + maxConsecutiveRepeats = maxConsecutiveRepeats, + terminalTool = terminalTool, + unfinishedTurn = unfinishedTurn, + ) + + /** The loop the ViewModel runs: ends on "respond", and asks an unfinished run to finish. */ + private fun finishingLoop() = agentLoop( + terminalTool = "respond", + unfinishedTurn = { UNFINISHED }, + ) + + /** [finishingLoop], also asking a run that owes a tool for it. */ + private fun verifyingLoop() = AgentLoop( + formatToolResults = ToolResultsPrompt({ shippedConfig }, "respond"), + terminalTool = "respond", + unfinishedTurn = { UNFINISHED }, + requiredToolTurn = { tool -> "$REQUIRED $tool" }, + ) + + /** Every tool name the run executed, in order. */ + private class ToolRecorder { + val names = mutableListOf() + suspend fun execute(calls: List): List { + names += calls.map { it.name } + return calls.map { ToolResult.success("ok", "result") } + } + } + + private val respond = """{"tool":"respond","args":{"message":"Done."}}""" + private fun toolCall(name: String) = """{"tool":"$name","args":{}}""" /** A call whose argument sets its signature apart from the same tool called on another path. */ @@ -39,7 +81,7 @@ class AgentLoopTest { val history = mutableListOf(ChatMessage(Role.USER, "hello")) var toolsInvoked = 0 - val result = AgentLoop().run( + val result = agentLoop().run( history = history, generate = model::generate, executeTools = { toolsInvoked++; emptyList() } @@ -65,7 +107,7 @@ class AgentLoopTest { var finalTurn = -1 var finalMessage: String? = null - val result = AgentLoop(terminalTool = "respond").run( + val result = agentLoop(terminalTool = "respond").run( history = history, generate = model::generate, executeTools = { toolsInvoked++; emptyList() }, @@ -94,7 +136,7 @@ class AgentLoopTest { val history = mutableListOf(ChatMessage(Role.USER, "hi")) var finalMessage: String? = null - val result = AgentLoop(terminalTool = "respond").run( + val result = agentLoop(terminalTool = "respond").run( history = history, generate = model::generate, executeTools = { emptyList() }, @@ -119,7 +161,7 @@ class AgentLoopTest { var toolsInvoked = 0 var finalMessage: String? = null - val result = AgentLoop(terminalTool = "respond").run( + val result = agentLoop(terminalTool = "respond").run( history = history, generate = model::generate, executeTools = { toolsInvoked++; emptyList() }, @@ -146,7 +188,7 @@ class AgentLoopTest { val history = mutableListOf(ChatMessage(Role.USER, "read MainActivity.kt")) val executed = mutableListOf>() - val result = AgentLoop().run( + val result = agentLoop().run( history = history, generate = { replies[turn++] }, executeTools = { calls -> @@ -175,7 +217,7 @@ class AgentLoopTest { var turn = 0 val history = mutableListOf(ChatMessage(Role.USER, "read MainActivity.kt")) - AgentLoop().run( + agentLoop().run( history = history, generate = { replies[turn++] }, executeTools = { listOf(ToolResult.success("contents", "MainActivity.kt")) } @@ -199,7 +241,7 @@ class AgentLoopTest { val history = mutableListOf(ChatMessage(Role.USER, "open MainActivity.java")) val executed = mutableListOf>() - val result = AgentLoop().run( + val result = agentLoop().run( history = history, generate = model::generate, executeTools = { calls -> @@ -245,7 +287,7 @@ class AgentLoopTest { var maxReachedTurns = -1 var toolBatches = 0 - val result = AgentLoop(maxIterations = 3).run( + val result = agentLoop(maxIterations = 3).run( history = history, generate = model::generate, executeTools = { toolBatches++; listOf(ToolResult.success("ok")) }, @@ -269,7 +311,7 @@ class AgentLoopTest { var repeatedTurns = -1 var toolBatches = 0 - val result = AgentLoop(maxIterations = 8).run( + val result = agentLoop(maxIterations = 8).run( history = history, generate = model::generate, executeTools = { toolBatches++; listOf(ToolResult.success("ok")) }, @@ -294,7 +336,7 @@ class AgentLoopTest { var toolBatches = 0 // A failed batch keeps the retry tolerance: one repeat allowed, the second aborts. - val result = AgentLoop(maxIterations = 8).run( + val result = agentLoop(maxIterations = 8).run( history = history, generate = model::generate, executeTools = { toolBatches++; listOf(ToolResult.failure("nope")) }, @@ -316,7 +358,7 @@ class AgentLoopTest { val history = mutableListOf(ChatMessage(Role.USER, "go")) var toolBatches = 0 - val result = AgentLoop(maxIterations = 8, maxConsecutiveRepeats = 1).run( + val result = agentLoop(maxIterations = 8, maxConsecutiveRepeats = 1).run( history = history, generate = model::generate, executeTools = { toolBatches++; listOf(ToolResult.failure("nope")) } @@ -340,7 +382,7 @@ class AgentLoopTest { val history = mutableListOf(ChatMessage(Role.USER, "open MainActivity")) val executed = mutableListOf>() - val result = AgentLoop(terminalTool = "respond").run( + val result = agentLoop(terminalTool = "respond").run( history = history, generate = model::generate, executeTools = { calls -> @@ -363,7 +405,7 @@ class AgentLoopTest { var toolsInvoked = 0 var finalMessage: String? = null - val result = AgentLoop(terminalTool = "respond").run( + val result = agentLoop(terminalTool = "respond").run( history = history, generate = model::generate, executeTools = { toolsInvoked++; emptyList() }, @@ -385,7 +427,7 @@ class AgentLoopTest { val modelTurns = mutableListOf() val toolTurns = mutableListOf() - AgentLoop().run( + agentLoop().run( history = history, generate = model::generate, executeTools = { listOf(ToolResult.success("contents")) }, @@ -404,7 +446,7 @@ class AgentLoopTest { val model = ScriptedModel(listOf(toolCall("open_file"), "acknowledged")) val history = mutableListOf(ChatMessage(Role.USER, "open nope")) - AgentLoop().run( + agentLoop().run( history = history, generate = model::generate, executeTools = { listOf(ToolResult.failure("File not found", "does not exist")) } @@ -421,7 +463,7 @@ class AgentLoopTest { val model = ScriptedModel(listOf(toolCall("read_file"), "ok")) val history = mutableListOf(ChatMessage(Role.USER, "read big")) - AgentLoop(toolOutputCharLimit = 500).run( + agentLoop(toolOutputCharLimit = 500).run( history = history, generate = model::generate, executeTools = { listOf(ToolResult.success("read", big)) } @@ -432,33 +474,9 @@ class AgentLoopTest { 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 loop = agentLoop() val transcript = loop.renderTranscript( listOf( ChatMessage(Role.USER, "hi"), @@ -482,7 +500,7 @@ class AgentLoopTest { val history = mutableListOf(ChatMessage(Role.USER, "add a view")) var reported: ToolCallExtractor.UnparsedReply? = null - val result = AgentLoop().run( + val result = agentLoop().run( history = history, generate = model::generate, executeTools = { emptyList() }, @@ -503,7 +521,7 @@ class AgentLoopTest { val model = ScriptedModel(listOf("Hi! What shall we build?")) var reported = false - val result = AgentLoop().run( + val result = agentLoop().run( history = mutableListOf(ChatMessage(Role.USER, "hi")), generate = model::generate, executeTools = { emptyList() }, @@ -524,7 +542,7 @@ class AgentLoopTest { val model = ScriptedModel(listOf(toolCall("open_file"), "I could not open that file.")) var abandonedTurn = 0 - val result = AgentLoop().run( + val result = agentLoop().run( history = mutableListOf(ChatMessage(Role.USER, "open nope")), generate = model::generate, executeTools = { listOf(ToolResult.failure("File not found", "does not exist")) }, @@ -546,7 +564,7 @@ class AgentLoopTest { val model = ScriptedModel(listOf(toolCall("open_file"), "Opened it.")) var abandoned = false - val result = AgentLoop().run( + val result = agentLoop().run( history = mutableListOf(ChatMessage(Role.USER, "open A.kt")), generate = model::generate, executeTools = { listOf(ToolResult.success("Opened", "A.kt")) }, @@ -581,7 +599,7 @@ class AgentLoopTest { var maxReachedTurns = -1 var toolBatches = 0 - val result = AgentLoop(maxIterations = 16).run( + val result = agentLoop(maxIterations = 16).run( history = history, generate = model::generate, executeTools = { toolBatches++; listOf(ToolResult.success("ok")) }, @@ -616,7 +634,7 @@ class AgentLoopTest { ) val history = mutableListOf(ChatMessage(Role.USER, "read the sources")) - val result = AgentLoop(maxIterations = 16).run( + val result = agentLoop(maxIterations = 16).run( history = history, generate = model::generate, executeTools = { listOf(ToolResult.success("ok")) } @@ -634,7 +652,7 @@ class AgentLoopTest { val history = mutableListOf(ChatMessage(Role.USER, "go")) var cycledTurns = -1 - val result = AgentLoop(maxIterations = 16).run( + val result = agentLoop(maxIterations = 16).run( history = history, generate = model::generate, executeTools = { listOf(ToolResult.failure("nope")) }, @@ -657,7 +675,7 @@ class AgentLoopTest { val history = mutableListOf(ChatMessage(Role.USER, "summarise the sources")) var cycledTurns = -1 - val result = AgentLoop(maxIterations = 16).run( + val result = agentLoop(maxIterations = 16).run( history = history, generate = model::generate, executeTools = { listOf(ToolResult.success("ok")) }, @@ -689,7 +707,7 @@ class AgentLoopTest { var repeatAfterSuccessTurn = -1 var cycledTurns = -1 - val result = AgentLoop(maxIterations = 16).run( + val result = agentLoop(maxIterations = 16).run( history = history, generate = model::generate, executeTools = { listOf(ToolResult.success("ok")) }, @@ -704,4 +722,214 @@ class AgentLoopTest { assertEquals(5, cycledTurns) assertEquals("a circling run must not be reported as completed", -1, repeatAfterSuccessTurn) } + + @Test + fun givenProseAfterATool_whenTheLoopRuns_thenItAsksToFinishAndEndsOnTheTerminalTool() = runTest { + val model = ScriptedModel(listOf(toolCall("web_search"), "Here is the design…", respond)) + val history = mutableListOf(ChatMessage(Role.USER, "research, design, code")) + var askedAt = 0 + var answer: String? = null + + val result = finishingLoop().run( + history = history, + generate = model::generate, + executeTools = { listOf(ToolResult.success("results")) }, + events = object : AgentLoop.Events { + override suspend fun onUnfinishedReply(turn: Int) { + askedAt = turn + } + + override suspend fun onFinalAnswer(turn: Int, message: String) { + answer = message + } + } + ) + + assertEquals(AgentLoop.StopReason.COMPLETED, result.reason) + assertEquals(3, result.turns) + assertEquals(2, askedAt) + assertEquals("Done.", answer) + val ask = history[history.size - 2] + assertEquals(Role.USER, ask.role) + assertEquals(UNFINISHED, ask.content) + } + + @Test + fun givenProseTwiceAfterATool_whenTheLoopRuns_thenItAsksOnlyOnceAndStops() = runTest { + val model = ScriptedModel(listOf(toolCall("web_search"), "Next, I will…", "Still prose.")) + var asks = 0 + + val result = finishingLoop().run( + history = mutableListOf(ChatMessage(Role.USER, "do it")), + generate = model::generate, + executeTools = { listOf(ToolResult.success("results")) }, + events = object : AgentLoop.Events { + override suspend fun onUnfinishedReply(turn: Int) { + asks++ + } + } + ) + + assertEquals(AgentLoop.StopReason.COMPLETED, result.reason) + assertEquals(3, result.turns) + assertEquals(1, asks) + } + + @Test + fun givenANewToolBatchAfterAnAsk_whenProseFollowsAgain_thenItAsksAgain() = runTest { + val model = ScriptedModel( + listOf(toolCall("web_search"), "prose", toolCall("read_file"), "prose", respond) + ) + var asks = 0 + + val result = finishingLoop().run( + history = mutableListOf(ChatMessage(Role.USER, "do it")), + generate = model::generate, + executeTools = { listOf(ToolResult.success("ok")) }, + events = object : AgentLoop.Events { + override suspend fun onUnfinishedReply(turn: Int) { + asks++ + } + } + ) + + assertEquals(AgentLoop.StopReason.COMPLETED, result.reason) + assertEquals(5, result.turns) + assertEquals(2, asks) + } + + @Test + fun givenAnAnswerWithNoToolsRun_whenTheLoopRuns_thenItEndsWithoutAsking() = runTest { + val model = ScriptedModel(listOf("Kotlin is a language.")) + var asked = false + + val result = finishingLoop().run( + history = mutableListOf(ChatMessage(Role.USER, "what is kotlin")), + generate = model::generate, + executeTools = { emptyList() }, + events = object : AgentLoop.Events { + override suspend fun onUnfinishedReply(turn: Int) { + asked = true + } + } + ) + + assertEquals(AgentLoop.StopReason.COMPLETED, result.reason) + assertEquals(1, result.turns) + assertFalse(asked) + } + + @Test + fun givenAFailedToolThenProseTwice_whenTheLoopRuns_thenItIsAbandonedAfterTheAsk() = runTest { + val model = ScriptedModel(listOf(toolCall("open_file"), "It failed.", "I give up.")) + + val result = finishingLoop().run( + history = mutableListOf(ChatMessage(Role.USER, "open nope")), + generate = model::generate, + executeTools = { listOf(ToolResult.failure("File not found")) }, + ) + + assertEquals(AgentLoop.StopReason.ABANDONED, result.reason) + assertEquals(3, result.turns) + } + + @Test + fun givenARequiredTool_whenTheModelAnswersWithoutIt_thenItIsAskedOnceAndTheRunContinues() = runTest { + val model = ScriptedModel(listOf("Looks correct and modern.", toolCall("web_search"), respond)) + val tools = ToolRecorder() + val skipped = mutableListOf() + val history = mutableListOf(ChatMessage(Role.USER, "review this")) + + val result = verifyingLoop().run( + history = history, + generate = model::generate, + executeTools = tools::execute, + requiredTool = "web_search", + events = object : AgentLoop.Events { + override suspend fun onRequiredToolSkipped(turn: Int, tool: String) { skipped += tool } + }, + ) + + assertTrue(result.completed) + assertEquals(3, result.turns) + assertEquals(listOf("web_search"), skipped) + assertEquals(listOf("web_search"), tools.names) + assertEquals("$REQUIRED web_search", history[2].content) + } + + @Test + fun givenARequiredTool_whenTheModelAnswersThroughTheTerminalToolFirst_thenItIsAskedForTheTool() = runTest { + val model = ScriptedModel(listOf(respond, toolCall("web_search"), respond)) + val tools = ToolRecorder() + var finalAnswers = 0 + + val result = verifyingLoop().run( + history = mutableListOf(ChatMessage(Role.USER, "is this deprecated?")), + generate = model::generate, + executeTools = tools::execute, + requiredTool = "web_search", + events = object : AgentLoop.Events { + override suspend fun onFinalAnswer(turn: Int, message: String) { finalAnswers++ } + }, + ) + + assertTrue(result.completed) + assertEquals(listOf("web_search"), tools.names) + assertEquals(1, finalAnswers) + } + + @Test + fun givenARequiredTool_whenTheModelCallsItFirst_thenItIsNeverAskedFor() = runTest { + val model = ScriptedModel(listOf(toolCall("web_search"), "Here is the review.", respond)) + var skipped = 0 + + val result = verifyingLoop().run( + history = mutableListOf(ChatMessage(Role.USER, "review this")), + generate = model::generate, + executeTools = ToolRecorder()::execute, + requiredTool = "web_search", + events = object : AgentLoop.Events { + override suspend fun onRequiredToolSkipped(turn: Int, tool: String) { skipped++ } + }, + ) + + assertTrue(result.completed) + assertEquals(0, skipped) + } + + @Test + fun givenARequiredTool_whenTheModelNeverCallsIt_thenItIsAskedOnlyOnceAndTheRunEnds() = runTest { + val model = ScriptedModel(listOf("Looks fine.", "Still looks fine.")) + val tools = ToolRecorder() + + val result = verifyingLoop().run( + history = mutableListOf(ChatMessage(Role.USER, "review this")), + generate = model::generate, + executeTools = tools::execute, + requiredTool = "web_search", + ) + + assertTrue(result.completed) + assertEquals(2, result.turns) + assertTrue(tools.names.isEmpty()) + } + + @Test + fun givenNoRequiredTool_whenTheModelAnswersDirectly_thenTheRunEndsOnThatAnswer() = runTest { + val model = ScriptedModel(listOf("Hello!")) + + val result = verifyingLoop().run( + history = mutableListOf(ChatMessage(Role.USER, "hi")), + generate = model::generate, + executeTools = ToolRecorder()::execute, + ) + + assertTrue(result.completed) + assertEquals(1, result.turns) + } + + private companion object { + const val UNFINISHED = "finish or carry on" + const val REQUIRED = "call first:" + } } 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 dafe3935..ef8f48c4 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 @@ -1,6 +1,7 @@ package com.itsaky.androidide.plugins.aicore.tool import com.itsaky.androidide.plugins.aicore.models.ToolResult +import com.itsaky.androidide.plugins.aicore.prompt.config.DirectoryPromptConfigSource.Companion.shippedConfig import com.itsaky.androidide.plugins.aicore.tool.handlers.PathGuard import kotlinx.coroutines.delay import kotlinx.coroutines.runBlocking @@ -67,7 +68,7 @@ class ExecutorTest { } private fun executorFor(handler: ToolHandler): Executor = - Executor(ToolRouter(listOf(handler)), ToolApprovalManager()) + Executor(ToolRouter(listOf(handler)), ToolApprovalManager({ shippedConfig })) @Test fun givenAnInternallyResolvingHandler_whenExecutingAnEscapingPath_thenTheEscapePreGuardIsBypassed() = runBlocking { @@ -226,7 +227,7 @@ class ExecutorTest { return ToolResult.success("edited") } } - val approvalManager = ToolApprovalManager() + val approvalManager = ToolApprovalManager({ shippedConfig }) val executor = Executor(ToolRouter(listOf(handler)), approvalManager) val results = executor.execute( @@ -270,7 +271,7 @@ class ExecutorTest { return ToolResult.success("edited") } } - val executor = Executor(ToolRouter(listOf(read, write)), ToolApprovalManager()) + val executor = Executor(ToolRouter(listOf(read, write)), ToolApprovalManager({ shippedConfig })) executor.execute( listOf( @@ -305,7 +306,7 @@ class ExecutorTest { return ToolResult.success("contents") } } - val executor = Executor(ToolRouter(listOf(write, read)), ToolApprovalManager()) + val executor = Executor(ToolRouter(listOf(write, read)), ToolApprovalManager({ shippedConfig })) executor.execute( listOf( @@ -333,7 +334,7 @@ class ExecutorTest { return ToolResult.success("contents") } } - val executor = Executor(ToolRouter(listOf(handler)), ToolApprovalManager()) + val executor = Executor(ToolRouter(listOf(handler)), ToolApprovalManager({ shippedConfig })) executor.execute( listOf( @@ -361,7 +362,7 @@ class ExecutorTest { return ToolResult.success("contents") } } - val executor = Executor(ToolRouter(listOf(handler)), ToolApprovalManager()) + val executor = Executor(ToolRouter(listOf(handler)), ToolApprovalManager({ shippedConfig })) executor.execute( listOf( @@ -390,7 +391,7 @@ class ExecutorTest { override suspend fun execute(args: Map) = ToolResult.success("wrote:${args["file_path"]}") } - val executor = Executor(ToolRouter(listOf(read, write)), ToolApprovalManager()) + val executor = Executor(ToolRouter(listOf(read, write)), ToolApprovalManager({ shippedConfig })) val results = executor.execute( listOf( @@ -425,7 +426,7 @@ class ExecutorTest { return ToolResult.success("edited") } } - val approvalManager = ToolApprovalManager() + val approvalManager = ToolApprovalManager({ shippedConfig }) val executor = Executor(ToolRouter(listOf(handler)), approvalManager) val results = executor.execute( diff --git a/plugins/AI-Core/src/test/kotlin/com/itsaky/androidide/plugins/aicore/tool/ToolApprovalManagerTest.kt b/plugins/AI-Core/src/test/kotlin/com/itsaky/androidide/plugins/aicore/tool/ToolApprovalManagerTest.kt index 3722c107..7163ff2b 100644 --- a/plugins/AI-Core/src/test/kotlin/com/itsaky/androidide/plugins/aicore/tool/ToolApprovalManagerTest.kt +++ b/plugins/AI-Core/src/test/kotlin/com/itsaky/androidide/plugins/aicore/tool/ToolApprovalManagerTest.kt @@ -1,6 +1,7 @@ package com.itsaky.androidide.plugins.aicore.tool import com.itsaky.androidide.plugins.aicore.models.ToolResult +import com.itsaky.androidide.plugins.aicore.prompt.config.DirectoryPromptConfigSource.Companion.shippedConfig import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.async import kotlinx.coroutines.delay @@ -48,7 +49,7 @@ class ToolApprovalManagerTest { @Test fun givenAToolDeclaringNoApproval_whenApprovalIsRequested_thenItRunsWithNoDialog() = runBlocking { // The handler's own declaration is the whole gate now; no name list is consulted. - val manager = ToolApprovalManager() + val manager = ToolApprovalManager({ shippedConfig }) val readOnly = object : ToolHandler { override val toolName = "read_file" override val description = "fake read" @@ -66,7 +67,7 @@ class ToolApprovalManagerTest { fun givenAToolDeclaringApproval_whenItIsNamedLikeAFormerlyExemptTool_thenTheUserIsStillAsked() { // `gradle_sync` and `generate_from_template` were exempted by name ahead of what their // handlers asked for, which is how the two tools that act on the project ran unprompted. - val manager = ToolApprovalManager() + val manager = ToolApprovalManager({ shippedConfig }) val syncing = object : ToolHandler { override val toolName = "gradle_sync" override val description = "fake sync" @@ -90,7 +91,7 @@ class ToolApprovalManagerTest { @Test fun givenACorrection_whenApprovalIsRequested_thenItIsNotApprovedAndTheInstructionIsRelayed() { - val manager = ToolApprovalManager() + val manager = ToolApprovalManager({ shippedConfig }) val response = decideWith(manager, ApprovalResult.CORRECTED, "keep the original method name") @@ -103,7 +104,7 @@ class ToolApprovalManagerTest { @Test fun givenACorrectionWithNoText_whenApprovalIsRequested_thenItStillReadsAsARevisionRequest() { - val manager = ToolApprovalManager() + val manager = ToolApprovalManager({ shippedConfig }) val response = decideWith(manager, ApprovalResult.CORRECTED, " ") @@ -114,7 +115,7 @@ class ToolApprovalManagerTest { @Test fun givenSessionApprovalOfAnEdit_whenAskedAgain_thenTheUserIsAskedAgain() { // Keyed by tool name alone, so honouring it would grant unreviewed writes to every file. - val manager = ToolApprovalManager() + val manager = ToolApprovalManager({ shippedConfig }) val first = decideWith(manager, ApprovalResult.APPROVED_FOR_SESSION) assertTrue(first.approved) @@ -125,7 +126,7 @@ class ToolApprovalManagerTest { @Test fun givenSessionApprovalOfANonDestructiveTool_whenAskedAgain_thenItIsRemembered() = runBlocking { - val manager = ToolApprovalManager() + val manager = ToolApprovalManager({ shippedConfig }) val handler = object : ToolHandler { override val toolName = "add_dependency" override val description = "fake" @@ -144,7 +145,7 @@ class ToolApprovalManagerTest { @Test fun givenTwoConcurrentRequests_whenBothAreAnswered_thenNeitherCallerIsStranded() = runBlocking { // One slot and one dialog: a second request used to overwrite it and strand the first. - val manager = ToolApprovalManager() + val manager = ToolApprovalManager({ shippedConfig }) val first = async(Dispatchers.Default) { manager.ensureApproved("edit_file", approvableHandler, mapOf("file_path" to "A.kt")) @@ -172,7 +173,7 @@ class ToolApprovalManagerTest { @Test fun givenACancelledRun_whenApprovalWasPending_thenNoStaleRequestKeepsTheDialogUp() = runBlocking { // Cancelling at the await must still clear the request, or the dialog stays on screen. - val manager = ToolApprovalManager() + val manager = ToolApprovalManager({ shippedConfig }) val pending = async(Dispatchers.Default) { manager.ensureApproved("edit_file", approvableHandler, mapOf("file_path" to "A.kt")) @@ -189,7 +190,7 @@ class ToolApprovalManagerTest { fun givenAContributedTool_whenApprovedForTheSession_thenTheUserIsStillAskedAgain() { // A contributed tool runs outside PathGuard and cannot be enumerated in a name list, so // "Always Allow" has to be refused by the handler's own declaration. - val manager = ToolApprovalManager() + val manager = ToolApprovalManager({ shippedConfig }) val contributed = object : ToolHandler { override val toolName = "aiagentmcp_create_issue" override val description = "creates an issue on a remote server" @@ -233,7 +234,7 @@ class ToolApprovalManagerTest { @Test fun givenADenial_whenApprovalIsRequested_thenItReportsTheDenial() { - val manager = ToolApprovalManager() + val manager = ToolApprovalManager({ shippedConfig }) val response = decideWith(manager, ApprovalResult.DENIED) diff --git a/plugins/AI-Core/src/test/kotlin/com/itsaky/androidide/plugins/aicore/tool/ToolCallExtractorTest.kt b/plugins/AI-Core/src/test/kotlin/com/itsaky/androidide/plugins/aicore/tool/ToolCallExtractorTest.kt index a3350185..7fbf6031 100644 --- a/plugins/AI-Core/src/test/kotlin/com/itsaky/androidide/plugins/aicore/tool/ToolCallExtractorTest.kt +++ b/plugins/AI-Core/src/test/kotlin/com/itsaky/androidide/plugins/aicore/tool/ToolCallExtractorTest.kt @@ -223,6 +223,107 @@ class ToolCallExtractorTest { assertEquals(reply, ToolCallExtractor.proseOutsideToolCalls(reply)) } + /** + * The answer the prompt's SCOPE clause exists to allow (ADFA-6223): an off-domain question + * whose answer is JSON that happens to carry a `tool` key. + */ + private val answerAboutToolSchemas = + "An MCP server advertises each tool like this:\n\n" + + "```json\n{\"tool\": \"search\", \"args\": { ... }}\n```\n\n" + + "The name is what the client calls." + + @Test + fun givenAnAnswerWhoseCodeFenceHoldsToolJson_whenExtracting_thenNoToolCallsAreProduced() { + assertTrue(ToolCallExtractor.extractToolCalls(answerAboutToolSchemas).isEmpty()) + } + + @Test + fun givenAnAnswerWhoseCodeFenceHoldsToolJson_whenDiagnosed_thenNothingIsReportedAsWrong() { + // Diagnosing it replaces a good answer with "the reply was malformed" and loses it. + assertNull(ToolCallExtractor.diagnoseUnparsedReply(answerAboutToolSchemas)) + } + + @Test + fun givenAnAnswerWhoseCodeFenceHoldsToolJson_whenStrippingCalls_thenTheAnswerSurvives() { + assertEquals(answerAboutToolSchemas, ToolCallExtractor.proseOutsideToolCalls(answerAboutToolSchemas)) + } + + @Test + fun givenAFencedExampleThatIsAValidCall_whenExtracting_thenNothingRuns() { + // Valid JSON, unlike the `{ ... }` above, so only the fence keeps the example from running. + val reply = "To delete a file the agent sends:\n\n```json\n" + + "{\"tool\":\"delete_file\",\"args\":{\"file_path\":\"A.kt\"}}\n```\n\nIt asks first." + + assertTrue(ToolCallExtractor.extractToolCalls(reply).isEmpty()) + } + + @Test + fun givenAnUnclosedFenceHoldingAValidCall_whenExtracting_thenNothingRuns() { + val reply = "Example:\n```\n{\"tool\":\"run_app\",\"args\":{}}" + + assertTrue(ToolCallExtractor.extractToolCalls(reply).isEmpty()) + } + + @Test + fun givenABareCallAfterAFencedExample_whenExtracting_thenOnlyTheCallOutsideRuns() { + val reply = "```json\n{\"tool\":\"delete_file\",\"args\":{\"file_path\":\"A.kt\"}}\n```\n" + + "{\"tool\":\"open_file\",\"args\":{\"file_path\":\"B.kt\"}}" + + val calls = ToolCallExtractor.extractToolCalls(reply) + + assertEquals(1, calls.size) + assertEquals("open_file", calls[0].name) + assertEquals("B.kt", calls[0].args["file_path"]) + } + + @Test + fun givenAnUnclosedCodeFenceHoldingToolJson_whenDiagnosed_thenNothingIsReportedAsWrong() { + // A reply cut off inside the fence is still an answer, not a call that failed to parse. + assertNull( + ToolCallExtractor.diagnoseUnparsedReply( + "Here is the config:\n\n```json\n{\"tool\": \"search\", \"args\": {" + ) + ) + } + + @Test + fun givenProseQuotingTheToolKeyBeforeABareCall_whenDiagnosed_thenTheCallIsStillFound() { + // Only the first match used to be tested for an object around it, so the sentence hid the call. + assertEquals( + ToolCallExtractor.UnparsedReply.MALFORMED, + ToolCallExtractor.diagnoseUnparsedReply( + "Each entry has a \"tool\": key.\n{\"tool\":\"open_file\",\"args\":{\"file_path\":\"A\"}" + ), + ) + } + + @Test + fun givenUnrelatedJsonBeforeProseQuotingTheToolKey_whenDiagnosed_thenNothingIsReportedAsWrong() { + // A closed object earlier in the reply does not put later prose inside an object. + val reply = "Data: {\"status\": \"ok\"}.\nNote that \"tool\": is a reserved word." + assertNull(ToolCallExtractor.diagnoseUnparsedReply(reply)) + } + + @Test + fun givenABareCallWhoseToolKeyFollowsANestedObject_whenDiagnosed_thenItStillReadsAsMalformed() { + // The nearest brace before the key closes the nested args; only depth sees the outer object. + assertEquals( + ToolCallExtractor.UnparsedReply.MALFORMED, + ToolCallExtractor.diagnoseUnparsedReply("{\"args\":{\"file_path\":\"A\"},\"tool\":\"open_file\""), + ) + } + + @Test + fun givenABareCallOutsideAFence_whenDiagnosed_thenItStillReadsAsMalformed() { + // The fence guard must not excuse a broken call that merely sits near one. + assertEquals( + ToolCallExtractor.UnparsedReply.MALFORMED, + ToolCallExtractor.diagnoseUnparsedReply( + "```kotlin\nval x = 1\n```\n{\"tool\":\"open_file\",\"args\":{\"file_path\":\"A\"}" + ), + ) + } + @Test fun givenArgumentsWithQuotesAndNewlines_whenRenderedAsAnEnvelope_thenTheyExtractBackUnchanged() { // The payload that cannot survive the model writing it by hand; rendering escapes it. diff --git a/plugins/AI-Core/src/test/kotlin/com/itsaky/androidide/plugins/aicore/tool/handlers/AddDependencyHandlerTest.kt b/plugins/AI-Core/src/test/kotlin/com/itsaky/androidide/plugins/aicore/tool/handlers/AddDependencyHandlerTest.kt new file mode 100644 index 00000000..c0dadde7 --- /dev/null +++ b/plugins/AI-Core/src/test/kotlin/com/itsaky/androidide/plugins/aicore/tool/handlers/AddDependencyHandlerTest.kt @@ -0,0 +1,72 @@ +package com.itsaky.androidide.plugins.aicore.tool.handlers + +import com.itsaky.androidide.plugins.PluginContext +import com.itsaky.androidide.plugins.ServiceRegistry +import com.itsaky.androidide.plugins.services.IdeProjectManipulationService +import io.mockk.every +import io.mockk.mockk +import io.mockk.slot +import io.mockk.verify +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 [AddDependencyHandler], which hands the host's service an absolute build file. */ +class AddDependencyHandlerTest { + + private lateinit var projectRoot: File + private lateinit var service: IdeProjectManipulationService + private lateinit var handler: AddDependencyHandler + private val path = slot() + + @Before + fun setup() { + projectRoot = Files.createTempDirectory("adddependency-project").toFile().canonicalFile + PathGuard.setProjectRootForTesting(projectRoot.absolutePath) + service = mockk() + every { service.addDependency(any(), capture(path)) } returns true + val services = mockk() + every { services.get(IdeProjectManipulationService::class.java) } returns service + val context = mockk() + every { context.services } returns services + handler = AddDependencyHandler(context) + } + + @After + fun tearDown() { + PathGuard.setProjectRootForTesting(null) + PathGuard.setProjectRootProvider(null) + projectRoot.deleteRecursively() + } + + @Test + fun givenNoBuildFile_whenAdding_thenTheServiceGetsTheAppBuildFileUnderTheProjectRoot() = + runBlocking { + val result = handler.execute(mapOf("dependency" to "io.ktor:ktor-client-okhttp:3.0.0")) + + assertTrue(result.success) + assertEquals(File(projectRoot, "app/build.gradle.kts").absolutePath, path.captured) + } + + @Test + fun givenARelativeBuildFile_whenAdding_thenTheServiceGetsItResolvedAgainstTheProjectRoot() = + runBlocking { + handler.execute(mapOf("dependency" to "a:b:1", "build_file" to "lib/build.gradle.kts")) + + assertEquals(File(projectRoot, "lib/build.gradle.kts").absolutePath, path.captured) + } + + @Test + fun givenABuildFileOutsideTheProject_whenAdding_thenTheServiceIsNeverCalled() = runBlocking { + val result = handler.execute(mapOf("dependency" to "a:b:1", "build_file" to "../elsewhere.kts")) + + assertFalse(result.success) + verify(exactly = 0) { service.addDependency(any(), any()) } + } +} diff --git a/plugins/AI-Core/src/test/kotlin/com/itsaky/androidide/plugins/aicore/tool/handlers/BuiltInHandlerApprovalTest.kt b/plugins/AI-Core/src/test/kotlin/com/itsaky/androidide/plugins/aicore/tool/handlers/BuiltInHandlerApprovalTest.kt index a3f327b7..4e762c05 100644 --- a/plugins/AI-Core/src/test/kotlin/com/itsaky/androidide/plugins/aicore/tool/handlers/BuiltInHandlerApprovalTest.kt +++ b/plugins/AI-Core/src/test/kotlin/com/itsaky/androidide/plugins/aicore/tool/handlers/BuiltInHandlerApprovalTest.kt @@ -39,7 +39,12 @@ class BuiltInHandlerApprovalTest { "search_project", "open_file", "read_build_output", + // Its query goes to the provider already holding the conversation; no new party sees it. + "web_search", ) + + /** Built-ins that reach a host the model chose, so the user sees where before it happens. */ + val OUTBOUND_TOOLS = setOf("fetch_url") } private val context = mockk(relaxed = true) @@ -52,7 +57,7 @@ class BuiltInHandlerApprovalTest { // So a new handler cannot be added without someone deciding which side it is on. assertEquals( "classify the new built-in below before shipping it", - MUTATING_TOOLS + READ_ONLY_TOOLS, + MUTATING_TOOLS + READ_ONLY_TOOLS + OUTBOUND_TOOLS, builtIns.mapTo(mutableSetOf()) { it.toolName }, ) } @@ -67,6 +72,21 @@ class BuiltInHandlerApprovalTest { ) } + @Test + fun givenABuiltInThatReachesAModelChosenHost_whenItIsRegistered_thenItRequiresApproval() { + val unguarded = builtIns.filter { it.toolName in OUTBOUND_TOOLS && !it.requiresApproval } + + assertTrue("these reach the network without asking: ${unguarded.map { it.toolName }}", unguarded.isEmpty()) + } + + @Test + fun givenTheBuiltIns_whenListed_thenBothWebToolsAreAlwaysAmongThem() { + val names = builtIns.map { it.toolName } + + assertTrue(names.contains("web_search")) + assertTrue(names.contains("fetch_url")) + } + @Test fun givenABuiltInThatOnlyReads_whenItIsRegistered_thenItNeedsNoApproval() { // The other half of the rule: a dialog on every read is what trains the user to tap through. diff --git a/plugins/AI-Core/src/test/kotlin/com/itsaky/androidide/plugins/aicore/tool/handlers/FetchUrlHandlerTest.kt b/plugins/AI-Core/src/test/kotlin/com/itsaky/androidide/plugins/aicore/tool/handlers/FetchUrlHandlerTest.kt new file mode 100644 index 00000000..d98b6542 --- /dev/null +++ b/plugins/AI-Core/src/test/kotlin/com/itsaky/androidide/plugins/aicore/tool/handlers/FetchUrlHandlerTest.kt @@ -0,0 +1,52 @@ +package com.itsaky.androidide.plugins.aicore.tool.handlers + +import com.itsaky.androidide.plugins.aicore.tool.Validation +import kotlinx.coroutines.runBlocking +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +/** + * Unit tests for [FetchUrlHandler.validate], which runs before the approval dialog: a URL the tool + * cannot fetch must be refused without spending the user's attention on it. + */ +class FetchUrlHandlerTest { + + private val handler = FetchUrlHandler() + + @Test + fun givenAnHttpsUrl_whenValidating_thenItIsAccepted() { + val result = validate("https://developer.android.com/jetpack/compose") + + assertTrue(result is Validation.Accepted) + } + + @Test + fun givenAGithubFilePage_whenValidating_thenTheDialogShowsTheRawUrlThatWillBeFetched() { + val result = validate("https://github.com/o/r/blob/main/build.gradle.kts") as Validation.Accepted + + assertEquals("https://raw.githubusercontent.com/o/r/main/build.gradle.kts", result.args["url"]) + } + + @Test + fun givenALocalFileUrl_whenValidating_thenItIsRefused() { + // Otherwise the tool would be a way around PathGuard's project containment. + val result = validate("file:///data/data/com.itsaky.androidide/databases/documentation.db") + + assertTrue(result is Validation.Rejected) + assertFalse((result as Validation.Rejected).result.success) + } + + @Test + fun givenNoUrl_whenValidating_thenItIsRefused() { + assertTrue(validate("") is Validation.Rejected) + } + + @Test + fun givenAUrlWithNoHost_whenValidating_thenItIsRefused() { + assertTrue(validate("https:///path") is Validation.Rejected) + } + + private fun validate(url: String): Validation = runBlocking { handler.validate(mapOf("url" to url)) } +} diff --git a/plugins/AI-Core/src/test/kotlin/com/itsaky/androidide/plugins/aicore/tool/handlers/RunAppHandlerTest.kt b/plugins/AI-Core/src/test/kotlin/com/itsaky/androidide/plugins/aicore/tool/handlers/RunAppHandlerTest.kt index 5195df78..5f07b86d 100644 --- a/plugins/AI-Core/src/test/kotlin/com/itsaky/androidide/plugins/aicore/tool/handlers/RunAppHandlerTest.kt +++ b/plugins/AI-Core/src/test/kotlin/com/itsaky/androidide/plugins/aicore/tool/handlers/RunAppHandlerTest.kt @@ -2,8 +2,10 @@ package com.itsaky.androidide.plugins.aicore.tool.handlers import com.itsaky.androidide.plugins.PluginContext import com.itsaky.androidide.plugins.ServiceRegistry -import com.itsaky.androidide.plugins.services.BuildAndLaunchCallback import com.itsaky.androidide.plugins.aicore.models.ToolResult +import com.itsaky.androidide.plugins.aicore.prompt.ToolDescriptions +import com.itsaky.androidide.plugins.aicore.prompt.config.DirectoryPromptConfigSource.Companion.shippedConfig +import com.itsaky.androidide.plugins.services.BuildAndLaunchCallback import com.itsaky.androidide.plugins.services.IdeBuildService import io.mockk.Runs import io.mockk.every @@ -153,7 +155,7 @@ class RunAppHandlerTest { // install prompt is answered. Without this caveat the model reports the app as running. assertTrue( "run_app success does not mean the app launched", - handler.description.contains("install started"), + ToolDescriptions.describe(shippedConfig, "respond", handler).contains("install started"), ) } diff --git a/plugins/AI-Core/src/test/kotlin/com/itsaky/androidide/plugins/aicore/tool/sources/ToolSourceStoreTest.kt b/plugins/AI-Core/src/test/kotlin/com/itsaky/androidide/plugins/aicore/tool/sources/ToolSourceStoreTest.kt index 1807f4e8..3a9530f9 100644 --- a/plugins/AI-Core/src/test/kotlin/com/itsaky/androidide/plugins/aicore/tool/sources/ToolSourceStoreTest.kt +++ b/plugins/AI-Core/src/test/kotlin/com/itsaky/androidide/plugins/aicore/tool/sources/ToolSourceStoreTest.kt @@ -1,6 +1,7 @@ package com.itsaky.androidide.plugins.aicore.tool.sources import com.itsaky.androidide.plugins.aicore.models.ToolResult +import com.itsaky.androidide.plugins.aicore.prompt.config.DirectoryPromptConfigSource.Companion.shippedConfig import com.itsaky.androidide.plugins.aicore.tool.AgentTools import com.itsaky.androidide.plugins.aicore.tool.ToolApprovalManager import com.itsaky.androidide.plugins.aicore.tool.ToolHandler @@ -42,7 +43,7 @@ class ToolSourceStoreTest { private fun toolsFrom(store: ToolSourceStore): AgentTools = AgentTools.build( builtInHandlers = builtIns, store = store, - approvalManager = ToolApprovalManager(), + approvalManager = ToolApprovalManager({ shippedConfig }), terminalTool = TERMINAL_TOOL, ) diff --git a/plugins/AI-Core/src/test/kotlin/com/itsaky/androidide/plugins/aicore/tool/web/BackendWebSearchTest.kt b/plugins/AI-Core/src/test/kotlin/com/itsaky/androidide/plugins/aicore/tool/web/BackendWebSearchTest.kt new file mode 100644 index 00000000..e9233230 --- /dev/null +++ b/plugins/AI-Core/src/test/kotlin/com/itsaky/androidide/plugins/aicore/tool/web/BackendWebSearchTest.kt @@ -0,0 +1,96 @@ +package com.itsaky.androidide.plugins.aicore.tool.web + +import com.itsaky.androidide.plugins.aicore.prompt.config.DirectoryPromptConfigSource.Companion.shippedConfig +import com.itsaky.androidide.plugins.services.LlmInferenceService.LlmBackend +import com.itsaky.androidide.plugins.services.LlmInferenceService.LlmConfig +import com.itsaky.androidide.plugins.services.LlmInferenceService.LlmResponse +import io.mockk.every +import io.mockk.mockk +import io.mockk.slot +import java.util.concurrent.CompletableFuture +import kotlinx.coroutines.runBlocking +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +/** Unit tests for [BackendWebSearch], the one-off request behind the web_search tool. */ +class BackendWebSearchTest { + + private val sent = slot() + + private fun backendAnswering(response: LlmResponse): LlmBackend = mockk { + every { generate(any(), capture(sent)) } returns CompletableFuture.completedFuture(response) + } + + private fun search(backend: LlmBackend?) = BackendWebSearch( + config = { shippedConfig }, + backendId = { "gemini" }, + backend = { backend }, + currentTime = { NOW }, + ) + + @Test + fun givenABackend_whenSearching_thenItIsAskedForAWebSearchByExtraParam() { + val backend = backendAnswering(LlmResponse.success("Kotlin 2.3 is current.", 5, 10)) + + runBlocking { search(backend).search("latest kotlin version") } + + assertEquals(true, sent.captured.extraParams?.get(WebAccess.EXTRA_PARAM_WEB_SEARCH)) + assertEquals("gemini", sent.captured.backendId) + } + + @Test + fun givenTheShippedConfig_whenSearching_thenTheReportingInstructionIsTheSystemPrompt() { + val backend = backendAnswering(LlmResponse.success("answer", 1, 1)) + + runBlocking { search(backend).search("q") } + + assertTrue(sent.captured.systemPrompt.orEmpty().startsWith("Search the web to answer the query.")) + } + + @Test + fun givenTheShippedConfig_whenSearching_thenLatestIsReadAsOfTheDevicesDate() { + val backend = backendAnswering(LlmResponse.success("answer", 1, 1)) + + runBlocking { search(backend).search("latest ktor version") } + + assertTrue(sent.captured.systemPrompt.orEmpty().contains("Today is $NOW;")) + } + + @Test + fun givenTheShippedConfig_whenChecked_thenTheInstructionRenders() { + assertEquals(emptyList(), BackendWebSearch.problems(shippedConfig)) + } + + @Test + fun givenAnAnswer_whenSearching_thenItComesBackAsTheResultData() { + val backend = backendAnswering(LlmResponse.success("Kotlin 2.3 is current.\n\nSources:\n- kotlinlang.org", 5, 10)) + + val result = runBlocking { search(backend).search("latest kotlin version") } + + assertTrue(result.success) + assertTrue(result.data!!.contains("Sources:")) + } + + @Test + fun givenABackendThatCannotSearch_whenSearching_thenItsRefusalIsTheFailure() { + val backend = backendAnswering(LlmResponse.failure("Web search is not available with the on-device model.")) + + val result = runBlocking { search(backend).search("q") } + + assertFalse(result.success) + assertEquals("Web search is not available with the on-device model.", result.message) + } + + @Test + fun givenNoBackend_whenSearching_thenItFailsWithoutThrowing() { + val result = runBlocking { search(null).search("q") } + + assertFalse(result.success) + } + + private companion object { + const val NOW = "Tuesday, 29 September 2026, 10:00 (UTC, UTC+00:00)" + } +} diff --git a/plugins/AI-Core/src/test/kotlin/com/itsaky/androidide/plugins/aicore/tool/web/VerificationPolicyTest.kt b/plugins/AI-Core/src/test/kotlin/com/itsaky/androidide/plugins/aicore/tool/web/VerificationPolicyTest.kt new file mode 100644 index 00000000..4d307dbb --- /dev/null +++ b/plugins/AI-Core/src/test/kotlin/com/itsaky/androidide/plugins/aicore/tool/web/VerificationPolicyTest.kt @@ -0,0 +1,110 @@ +package com.itsaky.androidide.plugins.aicore.tool.web + +import com.itsaky.androidide.plugins.services.LlmInferenceService.LlmConfig +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test + +/** + * Unit tests for [VerificationPolicy]. The snippets are the three the agent approved as current + * without searching (ADFA-6223); each has to trigger the search on its own. + */ +class VerificationPolicyTest { + + @Test + fun givenAnUnfencedComposeSnippet_whenChecked_thenASearchIsRequired() { + val message = """ + Review the following Kotlin snippet designed for an Android application using Jetpack Compose: + + @Composable + fun CameraScreen() { + val permissionState = rememberPermissionState(android.Manifest.permission.CAMERA) + if (permissionState.status.isGranted) { + Text("Camera access granted") + } + } + """.trimIndent() + + assertTrue(VerificationPolicy.requiresWebCheck(message)) + } + + @Test + fun givenAFencedSnippetLeftOpen_whenChecked_thenASearchIsRequired() { + val message = "Analyze this Android networking code written with Ktor Client:\n\n```kotlin\n" + + "val client = HttpClient(CIO) {\n install(JsonFeature) {\n serializer = KotlinxSerializer()\n" + + " }\n}" + + assertTrue(VerificationPolicy.requiresWebCheck(message)) + } + + @Test + fun givenAnActivitySnippet_whenChecked_thenASearchIsRequired() { + val message = """ + Examine the following legacy Android Activity configuration: + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + setContentView(R.layout.activity_main) + } + """.trimIndent() + + assertTrue(VerificationPolicy.requiresWebCheck(message)) + } + + @Test + fun givenAQuestionAboutWhatIsCurrent_whenChecked_thenASearchIsRequired() { + assertTrue(VerificationPolicy.requiresWebCheck("What is the latest version of Ktor?")) + assertTrue(VerificationPolicy.requiresWebCheck("Is Accompanist Permissions deprecated?")) + assertTrue(VerificationPolicy.requiresWebCheck("¿Cuál es la última versión de Compose?")) + assertTrue(VerificationPolicy.requiresWebCheck("ÚLTIMA VERSIÓN de Ktor")) + } + + @Test + fun givenAWordThatOnlyContainsAKeyword_whenChecked_thenItIsNotMatched() { + // "remigrate" and "prereview" hold keywords but are not them; the boundary must see letters. + assertFalse(VerificationPolicy.asksWhetherCurrent("remigrate the island")) + assertFalse(VerificationPolicy.asksForReview("a prereview party")) + assertFalse(VerificationPolicy.asksWhetherCurrent("sílegacy")) + } + + @Test + fun givenSmallTalkOrAPlainQuestion_whenChecked_thenNoSearchIsRequired() { + assertFalse(VerificationPolicy.requiresWebCheck("hi")) + assertFalse(VerificationPolicy.requiresWebCheck("What does a ViewModel do?")) + assertFalse(VerificationPolicy.requiresWebCheck("Rename count to itemCount in MainActivity.kt")) + } + + @Test + fun givenProseNamingOneCall_whenChecked_thenItIsNotReadAsCode() { + assertFalse(VerificationPolicy.containsCode("Why does\nlistOf() return an immutable list?")) + } + + @Test + fun givenAReviewRequest_whenFilesAreAttached_thenASearchIsRequiredOnlyThen() { + assertTrue(VerificationPolicy.requiresWebCheck("Review this file", hasAttachedFiles = true)) + assertFalse(VerificationPolicy.requiresWebCheck("Review this file", hasAttachedFiles = false)) + } + + @Test + fun givenAConfig_whenRequiringATool_thenOnlyTheCopyCarriesIt() { + val config = LlmConfig("gemini").apply { + temperature = 0.3f + maxTokens = 100 + systemPrompt = "system" + extraParams = mapOf("grammar" to "g") + } + + val required = VerificationPolicy.requiring(config, WebAccess.WEB_SEARCH_TOOL) + + assertEquals("gemini", required.backendId) + assertEquals(0.3f, required.temperature, 0f) + assertEquals(100, required.maxTokens) + assertEquals("system", required.systemPrompt) + assertEquals( + mapOf("grammar" to "g", WebAccess.EXTRA_PARAM_REQUIRED_TOOL to WebAccess.WEB_SEARCH_TOOL), + required.extraParams, + ) + assertNull(config.extraParams[WebAccess.EXTRA_PARAM_REQUIRED_TOOL]) + } +} diff --git a/plugins/AI-Core/src/test/kotlin/com/itsaky/androidide/plugins/aicore/tool/web/WebPageTextTest.kt b/plugins/AI-Core/src/test/kotlin/com/itsaky/androidide/plugins/aicore/tool/web/WebPageTextTest.kt new file mode 100644 index 00000000..13b840b4 --- /dev/null +++ b/plugins/AI-Core/src/test/kotlin/com/itsaky/androidide/plugins/aicore/tool/web/WebPageTextTest.kt @@ -0,0 +1,76 @@ +package com.itsaky.androidide.plugins.aicore.tool.web + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +/** Unit tests for [WebPageText], which decides what fetch_url requests and what it hands back. */ +class WebPageTextTest { + + @Test + fun givenAGithubFilePage_whenPreferringAUrl_thenTheRawFileIsFetchedInstead() { + val url = "https://github.com/appdevforall/CodeOnTheGo/blob/stage/README.md" + + assertEquals( + "https://raw.githubusercontent.com/appdevforall/CodeOnTheGo/stage/README.md", + WebPageText.preferredUrl(url), + ) + } + + @Test + fun givenARepositoryRoot_whenPreferringAUrl_thenItIsLeftAlone() { + val url = "https://github.com/appdevforall/CodeOnTheGo" + + assertEquals(url, WebPageText.preferredUrl(url)) + } + + @Test + fun givenAPage_whenReducingToText_thenScriptsStylesAndMarkupAreGone() { + val html = """ + Release notes +

    Version 2.0

    Adds web search.

    + """.trimIndent() + + val text = WebPageText.htmlToText(html) + + assertTrue(text.startsWith("Release notes\n\n")) + assertTrue(text.contains("Version 2.0\n")) + assertTrue(text.contains("Adds web search.")) + assertFalse(text.contains("track()")) + assertFalse(text.contains("color:red")) + assertFalse(text.contains("<")) + } + + @Test + fun givenAList_whenReducingToText_thenEachItemIsItsOwnDashedLine() { + val text = WebPageText.htmlToText("
    • one
    • two
    ") + + assertEquals("- one\n- two", text) + } + + @Test + fun givenEntities_whenReducingToText_thenTheyAreDecodedOnce() { + // "&lt;" spells the literal text "<", not a bracket. + val text = WebPageText.htmlToText("

    a < b && c — &lt;

    ") + + assertEquals("a < b && c — <", text) + } + + @Test + fun givenContentTypes_whenCheckingReadability_thenTextJsonAndXmlPassAndBinariesDoNot() { + assertTrue(WebPageText.isReadable("text/html; charset=utf-8")) + assertTrue(WebPageText.isReadable("application/json")) + assertTrue(WebPageText.isReadable("application/vnd.github+json")) + assertTrue(WebPageText.isReadable(null)) + assertFalse(WebPageText.isReadable("image/png")) + assertFalse(WebPageText.isReadable("application/zip")) + } + + @Test + fun givenNoContentType_whenCheckingForHtml_thenTheBodyDecides() { + assertTrue(WebPageText.isHtml(null, " ")) + assertFalse(WebPageText.isHtml(null, "# README")) + assertFalse(WebPageText.isHtml("text/plain", "")) + } +} diff --git a/plugins/AI-Core/src/test/kotlin/com/itsaky/androidide/plugins/aicore/viewmodel/AgentActivityTest.kt b/plugins/AI-Core/src/test/kotlin/com/itsaky/androidide/plugins/aicore/viewmodel/AgentActivityTest.kt index 78c06a37..efca5fee 100644 --- a/plugins/AI-Core/src/test/kotlin/com/itsaky/androidide/plugins/aicore/viewmodel/AgentActivityTest.kt +++ b/plugins/AI-Core/src/test/kotlin/com/itsaky/androidide/plugins/aicore/viewmodel/AgentActivityTest.kt @@ -1,8 +1,11 @@ package com.itsaky.androidide.plugins.aicore.viewmodel +import com.itsaky.androidide.plugins.aicore.models.ToolResult import com.itsaky.androidide.plugins.aicore.tool.ToolCall import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue import org.junit.Test /** @@ -83,4 +86,41 @@ class AgentActivityTest { fun givenNoToolsAtAll_whenSummarised_thenThereIsNothingToName() { assertEquals(emptyList(), AgentActivity.distinctNames(listOf("", " "))) } + + @Test + fun givenAWebSearch_whenLogged_thenTheQueryAndTheWholeReportAreKept() { + val call = ToolCall("web_search", mapOf("query" to "Ktor JsonFeature deprecated")) + val report = "JsonFeature was removed in Ktor 2.0.\n\nSources:\n- https://ktor.io/docs/migrating-2.html" + + val entry = AgentActivity.logEntry(call, ToolResult.success("Searched the web for: q", report)) + + assertEquals("✓ web_search(query=Ktor JsonFeature deprecated)\nSearched the web for: q\n$report", entry) + } + + @Test + fun givenAProjectRead_whenLogged_thenOnlyItsMessageIsKept() { + val call = ToolCall("read_file", mapOf("file_path" to "app/Secret.kt")) + + val entry = AgentActivity.logEntry(call, ToolResult.success("Read 3 lines", "val key = 1")) + + assertEquals("✓ read_file(file_path=app/Secret.kt)\nRead 3 lines", entry) + } + + @Test + fun givenAFailure_whenLogged_thenItsDetailsAreKept() { + val entry = AgentActivity.logEntry(ToolCall("open_file", emptyMap()), ToolResult.failure("not found", "no such file")) + + assertTrue(entry.startsWith("✗ open_file()")) + assertTrue(entry.endsWith("no such file")) + } + + @Test + fun givenALongArgument_whenLogged_thenItIsClipped() { + val call = ToolCall("create_file", mapOf("content" to "x".repeat(AgentActivity.LOG_ARG_LIMIT + 50))) + + val entry = AgentActivity.logEntry(call, ToolResult.success("created")) + + assertTrue(entry.contains("…[50 more chars]")) + assertFalse(entry.contains("x".repeat(AgentActivity.LOG_ARG_LIMIT + 1))) + } } diff --git a/plugins/AI-Core/src/test/kotlin/com/itsaky/androidide/plugins/aicore/viewmodel/AnswerReviewTest.kt b/plugins/AI-Core/src/test/kotlin/com/itsaky/androidide/plugins/aicore/viewmodel/AnswerReviewTest.kt new file mode 100644 index 00000000..08ee0dd3 --- /dev/null +++ b/plugins/AI-Core/src/test/kotlin/com/itsaky/androidide/plugins/aicore/viewmodel/AnswerReviewTest.kt @@ -0,0 +1,76 @@ +package com.itsaky.androidide.plugins.aicore.viewmodel + +import com.itsaky.androidide.plugins.aicore.prompt.config.DirectoryPromptConfigSource.Companion.shippedConfig +import com.itsaky.androidide.plugins.aicore.prompt.config.DirectoryPromptConfigSource.Companion.shippedWith +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test + +/** Unit tests for [AnswerReview], the second pass over an answer holding code. */ +class AnswerReviewTest { + + private val draft = "Use this:\n```kotlin\nval x = 1\n```" + + @Test + fun givenTheShippedConfig_whenChecked_thenEveryTextRenders() { + assertEquals(emptyList(), AnswerReview.problems(shippedConfig)) + } + + @Test + fun givenAReplyWithACodeBlock_whenChecked_thenItIsReviewed() { + assertTrue(AnswerReview.holdsCode(draft)) + assertFalse(AnswerReview.holdsCode("A ViewModel survives configuration changes.")) + } + + @Test + fun givenTheShippedConfig_whenBuildingTheSystemPrompt_thenItCarriesTheDateAndTheEndMarker() { + val prompt = AnswerReview.systemPrompt(shippedConfig, "Tuesday, 29 September 2026, 10:00 (UTC, UTC+00:00)") + + assertTrue(prompt.contains("Today is Tuesday, 29 September 2026")) + assertTrue(prompt.endsWith("a line holding only ${AnswerReview.END_MARKER}.")) + } + + @Test + fun givenEvidence_whenBuildingTheTurn_thenRequestEvidenceAndDraftAreSentVerbatim() { + val turn = AnswerReview.prompt(shippedConfig, "Review {{this}}", "✓ web_search(query=q)\nKtor 3.6.0", draft) + + assertEquals( + "REQUEST:\nReview {{this}}\n\nEVIDENCE:\n✓ web_search(query=q)\nKtor 3.6.0\n\nDRAFT:\n$draft", + turn, + ) + } + + @Test + fun givenNoEvidence_whenBuildingTheTurn_thenTheReviewIsToldNothingWasChecked() { + val turn = AnswerReview.prompt(shippedConfig, "q", " ", draft) + + assertTrue(turn.contains("EVIDENCE:\nNone. No tool ran")) + } + + @Test + fun givenAReplyEndingOnTheMarker_whenRead_thenTheCorrectedAnswerIsReturnedWithoutIt() { + val raw = "checkingFixed:\n```kotlin\nval x = 2\n```\n${AnswerReview.END_MARKER}\n" + + assertEquals("Fixed:\n```kotlin\nval x = 2\n```", AnswerReview.corrected(raw, draft)) + } + + @Test + fun givenAReplyCutOffBeforeTheMarker_whenRead_thenTheDraftStands() { + assertNull(AnswerReview.corrected("Fixed:\n```kotlin\nval x =", draft)) + } + + @Test + fun givenAReplyThatChangedNothing_whenRead_thenTheDraftStands() { + assertNull(AnswerReview.corrected("$draft\n${AnswerReview.END_MARKER}", draft)) + assertNull(AnswerReview.corrected(AnswerReview.END_MARKER, draft)) + } + + @Test + fun givenATypoInTheLayout_whenChecked_thenItIsReportedByItsFileAndPath() { + val config = shippedWith("layout.yml") { it.replace("{{DRAFT}}", "{{DRAFTT}}") } + + assertEquals(listOf("layout.yml: layout.answer_review: unknown name {{DRAFTT}}"), AnswerReview.problems(config)) + } +} diff --git a/plugins/AI-Core/src/test/kotlin/com/itsaky/androidide/plugins/aicore/viewmodel/ChatTitleTest.kt b/plugins/AI-Core/src/test/kotlin/com/itsaky/androidide/plugins/aicore/viewmodel/ChatTitleTest.kt index 3131fcfa..2a0573a0 100644 --- a/plugins/AI-Core/src/test/kotlin/com/itsaky/androidide/plugins/aicore/viewmodel/ChatTitleTest.kt +++ b/plugins/AI-Core/src/test/kotlin/com/itsaky/androidide/plugins/aicore/viewmodel/ChatTitleTest.kt @@ -1,5 +1,7 @@ package com.itsaky.androidide.plugins.aicore.viewmodel +import com.itsaky.androidide.plugins.aicore.prompt.config.DirectoryPromptConfigSource.Companion.shippedConfig +import com.itsaky.androidide.plugins.aicore.prompt.config.DirectoryPromptConfigSource.Companion.shippedWith import org.junit.Assert.assertEquals import org.junit.Assert.assertNull import org.junit.Assert.assertTrue @@ -47,10 +49,43 @@ class ChatTitleTest { @Test fun givenALongExchange_whenBuildingThePrompt_thenEachSideIsCutAndItEndsOnTheTitleCue() { // Letters that appear in none of the prompt's own labels. - val prompt = ChatTitle.prompt("q".repeat(5_000), "z".repeat(5_000)) + val prompt = ChatTitle.prompt(shippedConfig, "q".repeat(5_000), "z".repeat(5_000)) assertTrue(prompt.endsWith("Title:")) assertEquals(ChatTitle.EXCERPT_CHARS, prompt.count { it == 'q' }) assertEquals(ChatTitle.EXCERPT_CHARS, prompt.count { it == 'z' }) } + + @Test + fun givenTheShippedConfig_whenChecked_thenBothTextsRender() { + assertEquals(emptyList(), ChatTitle.problems(shippedConfig)) + } + + @Test + fun givenAnExchange_whenBuildingThePrompt_thenItIsFramedAsAConversation() { + assertEquals( + "Conversation:\nDeveloper: fix my build\nAssistant: Sync Gradle first.\n\nTitle:", + ChatTitle.prompt(shippedConfig, " fix my build ", "Sync Gradle first."), + ) + } + + @Test + fun givenAnExchangeThatLooksLikeATemplate_whenBuildingThePrompt_thenItIsInsertedVerbatim() { + // The user's own words are data; rendering them would throw on a stray tag. + assertTrue(ChatTitle.prompt(shippedConfig, "{{#X}} name", "ok").contains("{{#X}} name")) + } + + @Test + fun givenTheShippedConfig_whenBuildingTheSystemPrompt_thenItAsksForAShortPlainTitle() { + assertTrue(ChatTitle.systemPrompt(shippedConfig).startsWith("You name chat conversations")) + } + + @Test + fun givenANameTypoInTheLayout_whenChecked_thenTheProblemNamesTheText() { + val config = shippedWith("layout.yml") { it.replace("{{REPLY_TEXT}}", "{{REPLY_TXT}}") } + + val problems = ChatTitle.problems(config) + + assertEquals(listOf("layout.yml: layout.chat_title: unknown name {{REPLY_TXT}}"), problems) + } } diff --git a/plugins/AI-Core/src/test/kotlin/com/itsaky/androidide/plugins/aicore/viewmodel/ChatViewModelTitleQueueTest.kt b/plugins/AI-Core/src/test/kotlin/com/itsaky/androidide/plugins/aicore/viewmodel/ChatViewModelTitleQueueTest.kt index 8a44cbac..95edf000 100644 --- a/plugins/AI-Core/src/test/kotlin/com/itsaky/androidide/plugins/aicore/viewmodel/ChatViewModelTitleQueueTest.kt +++ b/plugins/AI-Core/src/test/kotlin/com/itsaky/androidide/plugins/aicore/viewmodel/ChatViewModelTitleQueueTest.kt @@ -2,6 +2,9 @@ package com.itsaky.androidide.plugins.aicore.viewmodel import android.content.Context import android.content.SharedPreferences +import com.itsaky.androidide.plugins.aicore.prompt.config.DirectoryPromptConfigSource +import com.itsaky.androidide.plugins.aicore.prompt.config.DirectoryPromptConfigSource.Companion.SHIPPED_ROOT +import com.itsaky.androidide.plugins.aicore.prompt.config.sharedPromptConfig import com.itsaky.androidide.plugins.services.LlmInferenceService import com.itsaky.androidide.plugins.services.SharedServices import io.mockk.every @@ -9,8 +12,11 @@ import io.mockk.mockk import io.mockk.verify import java.util.concurrent.CompletableFuture import java.util.concurrent.ConcurrentHashMap +import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.cancel import kotlinx.coroutines.runBlocking import kotlinx.coroutines.test.UnconfinedTestDispatcher import kotlinx.coroutines.test.resetMain @@ -49,6 +55,9 @@ class ChatViewModelTitleQueueTest { /** Backs the preferences mock; concurrent because the persist scope writes from its own thread. */ private val stored = ConcurrentHashMap() + /** Runs the prompt config load that activation starts on device; the title is worded from it. */ + private val configScope = CoroutineScope(SupervisorJob() + Dispatchers.Default) + @Before fun setUp() { // ChatViewModel's stateIn() calls run on viewModelScope, i.e. Dispatchers.Main. @@ -69,6 +78,7 @@ class ChatViewModelTitleQueueTest { // Like the real service: the global cancel reaches whatever call is current, here the title. every { llmService.cancelGeneration() } answers { titleResponse.cancel(true); Unit } SharedServices.register(LlmInferenceService::class.java, llmService) + runBlocking { sharedPromptConfig.preload(configScope, DirectoryPromptConfigSource(SHIPPED_ROOT)).await() } stored[SESSIONS_KEY] = """[{"id":"$SESSION_ID","createdAt":1000,"projectKey":"$TEST_PROJECT_KEY","messages":[""" + """{"id":"m1","text":"explain this build script","sender":"USER","status":"SENT","timestamp":1},""" + @@ -80,6 +90,8 @@ class ChatViewModelTitleQueueTest { fun tearDown() { Dispatchers.resetMain() SharedServices.clear() + sharedPromptConfig.clear() + configScope.cancel() } private fun newViewModel() = ChatViewModel { null }.apply { From ec4282c3ca403097f9f23dda2bbc9bf2c49bc1d5 Mon Sep 17 00:00:00 2001 From: John Trujillo Date: Wed, 30 Sep 2026 12:52:55 -0500 Subject: [PATCH 2/4] fix(ai-agent): address PR #114 review [ADFA-6223] Floor AI plugins at 26.41, run fenced tool calls, stop fetch_url at cross-host redirects, offer web_search only where the backend can search. --- plugins/AI-Agent-Gemini/src/main/AndroidManifest.xml | 2 +- .../plugins/aiagentgemini/backend/GeminiBackend.kt | 5 ++++- plugins/AI-Agent-Local/src/main/AndroidManifest.xml | 2 +- plugins/AI-Agent-MCP/src/main/AndroidManifest.xml | 2 +- plugins/AI-Agent-OpenAI/src/main/AndroidManifest.xml | 2 +- .../plugins/aiagentopenai/backend/OpenAiBackend.kt | 5 ++++- plugins/AI-Core/src/main/AndroidManifest.xml | 2 +- .../plugins/aicore/tool/ToolCallExtractor.kt | 8 ++++++-- .../plugins/aicore/tool/handlers/FetchUrlHandler.kt | 11 ++++++++--- .../androidide/plugins/aicore/tool/web/WebAccess.kt | 6 +++--- .../plugins/aicore/viewmodel/ChatViewModel.kt | 9 +++++++-- .../plugins/aicore/tool/ToolCallExtractorTest.kt | 10 ++++++++++ 12 files changed, 47 insertions(+), 17 deletions(-) diff --git a/plugins/AI-Agent-Gemini/src/main/AndroidManifest.xml b/plugins/AI-Agent-Gemini/src/main/AndroidManifest.xml index d12adecb..7289824a 100644 --- a/plugins/AI-Agent-Gemini/src/main/AndroidManifest.xml +++ b/plugins/AI-Agent-Gemini/src/main/AndroidManifest.xml @@ -44,7 +44,7 @@ 26.35 would name a release that cannot run this plugin. Do not lower it. --> + android:value="26.41" /> GeminiPromptConfig?, ) : HistoryCapableBackend, CancellableBackend, ConfigurableBackend, ToolCallingBackend, - EmbeddingBackend { + EmbeddingBackend, WebSearchBackend { private val scope = CoroutineScope(Dispatchers.IO) @@ -366,6 +366,9 @@ class GeminiBackend( override fun getSettingsFragmentClassName(): String = "com.itsaky.androidide.plugins.aiagentgemini.settings.GeminiSettingsFragment" + /** Searches through Google Search grounding; see [GeminiWebSearch]. */ + override fun canSearchWeb(): Boolean = true + override fun isAvailable(): Boolean { // Available once a (decryptable) API key is configured. val apiKey = readGeminiApiKey() diff --git a/plugins/AI-Agent-Local/src/main/AndroidManifest.xml b/plugins/AI-Agent-Local/src/main/AndroidManifest.xml index 3ec427d4..d24e7570 100644 --- a/plugins/AI-Agent-Local/src/main/AndroidManifest.xml +++ b/plugins/AI-Agent-Local/src/main/AndroidManifest.xml @@ -40,7 +40,7 @@ and pairs with ai-core, which requires the same release. --> + android:value="26.41" /> + android:value="26.41" /> + android:value="26.41" /> OpenAiPromptConfig?, ) : HistoryCapableBackend, CancellableBackend, ConfigurableBackend, ToolCallingBackend, - EmbeddingBackend { + EmbeddingBackend, WebSearchBackend { private val scope = CoroutineScope(Dispatchers.IO) @@ -301,6 +301,9 @@ class OpenAiBackend( override fun getSettingsFragmentClassName(): String = "com.itsaky.androidide.plugins.aiagentopenai.settings.OpenAiSettingsFragment" + /** Only OpenAI's own Responses API searches; a compatible server refuses the request. */ + override fun canSearchWeb(): Boolean = BaseUrlPolicy.isOpenAiApi(getBaseUrl()) + /** * Available when the server can plausibly be called. * diff --git a/plugins/AI-Core/src/main/AndroidManifest.xml b/plugins/AI-Core/src/main/AndroidManifest.xml index 0759e86e..f3b53d8c 100644 --- a/plugins/AI-Core/src/main/AndroidManifest.xml +++ b/plugins/AI-Core/src/main/AndroidManifest.xml @@ -41,7 +41,7 @@ + android:value="26.41" /> - currentBackendId != AiBackend.LOCAL_ID && - toolDefinitions.any { it.name == search } && + toolDefinitions.any { it.name == search } && VerificationPolicy.requiresWebCheck(userMessage, contextFiles.isNotEmpty()) } requiredTool?.let { AgentTrace.stage("VERIFY", "required=$it on the first turn") } diff --git a/plugins/AI-Core/src/test/kotlin/com/itsaky/androidide/plugins/aicore/tool/ToolCallExtractorTest.kt b/plugins/AI-Core/src/test/kotlin/com/itsaky/androidide/plugins/aicore/tool/ToolCallExtractorTest.kt index 7fbf6031..adfa9347 100644 --- a/plugins/AI-Core/src/test/kotlin/com/itsaky/androidide/plugins/aicore/tool/ToolCallExtractorTest.kt +++ b/plugins/AI-Core/src/test/kotlin/com/itsaky/androidide/plugins/aicore/tool/ToolCallExtractorTest.kt @@ -257,6 +257,16 @@ class ToolCallExtractorTest { assertTrue(ToolCallExtractor.extractToolCalls(reply).isEmpty()) } + @Test + fun givenAReplyThatIsOnlyAFencedCall_whenExtracting_thenTheCallRuns() { + val reply = "```json\n{\"tool\":\"read_file\",\"args\":{\"file_path\":\"app/build.gradle.kts\"}}\n```" + + val calls = ToolCallExtractor.extractToolCalls(reply) + + assertEquals(1, calls.size) + assertEquals("read_file", calls[0].name) + } + @Test fun givenAnUnclosedFenceHoldingAValidCall_whenExtracting_thenNothingRuns() { val reply = "Example:\n```\n{\"tool\":\"run_app\",\"args\":{}}" From 5c72a138608a17973dc0365ad9d26a4ecb396a7a Mon Sep 17 00:00:00 2001 From: John Trujillo Date: Wed, 30 Sep 2026 14:15:48 -0500 Subject: [PATCH 3/4] refactor(ai-agent): load prompt config via PromptConfigStore.reload [ADFA-6223] Drops the preload/release copies from AI-Core, Gemini, OpenAI and Local. --- .../aiagentgemini/plugin/GeminiPlugin.kt | 39 ++++--------------- .../aiagentlocal/plugin/LocalLlmPlugin.kt | 39 ++++--------------- .../aiagentopenai/plugin/OpenAiPlugin.kt | 39 ++++--------------- .../plugins/aicore/plugin/AiCorePlugin.kt | 23 +---------- 4 files changed, 23 insertions(+), 117 deletions(-) diff --git a/plugins/AI-Agent-Gemini/src/main/kotlin/com/itsaky/androidide/plugins/aiagentgemini/plugin/GeminiPlugin.kt b/plugins/AI-Agent-Gemini/src/main/kotlin/com/itsaky/androidide/plugins/aiagentgemini/plugin/GeminiPlugin.kt index 3a5ff5db..5ee78553 100644 --- a/plugins/AI-Agent-Gemini/src/main/kotlin/com/itsaky/androidide/plugins/aiagentgemini/plugin/GeminiPlugin.kt +++ b/plugins/AI-Agent-Gemini/src/main/kotlin/com/itsaky/androidide/plugins/aiagentgemini/plugin/GeminiPlugin.kt @@ -14,12 +14,6 @@ import com.itsaky.androidide.plugins.extensions.PluginTooltipButton import com.itsaky.androidide.plugins.extensions.PluginTooltipEntry import com.itsaky.androidide.plugins.services.LlmInferenceService import com.itsaky.androidide.plugins.services.SharedServices -import kotlinx.coroutines.CancellationException -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.ExperimentalCoroutinesApi -import kotlinx.coroutines.SupervisorJob -import kotlinx.coroutines.cancel /** * Registers the Google Gemini API backend with AI Core's inference router. @@ -42,9 +36,6 @@ class GeminiPlugin : IPlugin, DocumentationExtension { /** True once [backend] is registered with the router, so re-registration is idempotent. */ @Volatile private var registered = false - /** Runs the prompt-config load while the plugin is active; cancelled on deactivation. */ - @Volatile private var configScope: CoroutineScope? = null - companion object { const val PLUGIN_ID = "com.itsaky.androidide.plugins.aiagentgemini" @@ -193,22 +184,13 @@ class GeminiPlugin : IPlugin, DocumentationExtension { } /** Reads and validates the prompt config now, so building a prompt does no disk I/O. */ - @OptIn(ExperimentalCoroutinesApi::class) private fun preloadPromptConfig() { - releasePromptConfig() - val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default) - configScope = scope val source = AssetPromptConfigSource(context.androidContext.assets) - val load = sharedPromptConfig.preload(scope, source) - load.invokeOnCompletion { error -> - when (error) { - null -> reportLoadedConfig(load.getCompleted()) - is CancellationException -> Unit - else -> context.logger.error( - "GeminiPlugin: prompt config failed to load; ai-core's default prompt is sent instead", - error, - ) - } + sharedPromptConfig.reload(source, ::reportLoadedConfig) { error -> + context.logger.error( + "GeminiPlugin: prompt config failed to load; ai-core's default prompt is sent instead", + error, + ) } } @@ -224,13 +206,6 @@ class GeminiPlugin : IPlugin, DocumentationExtension { } } - /** Drops the cached config and stops a load still in flight. Idempotent. */ - private fun releasePromptConfig() { - sharedPromptConfig.clear() - configScope?.cancel() - configScope = null - } - override fun deactivate(): Boolean { context.logger.info("GeminiPlugin: Deactivating plugin") @@ -246,7 +221,7 @@ class GeminiPlugin : IPlugin, DocumentationExtension { // A disabled plugin must not keep the decrypted key on the host heap. releaseBackend() - releasePromptConfig() + sharedPromptConfig.clear() true } catch (e: Exception) { @@ -273,7 +248,7 @@ class GeminiPlugin : IPlugin, DocumentationExtension { runCatching { context.removePluginLifecycleListener(aiCoreLifecycle) } releaseBackend() - releasePromptConfig() + sharedPromptConfig.clear() pluginContext = null context.logger.info("GeminiPlugin: Released Gemini backend") } diff --git a/plugins/AI-Agent-Local/src/main/kotlin/com/itsaky/androidide/plugins/aiagentlocal/plugin/LocalLlmPlugin.kt b/plugins/AI-Agent-Local/src/main/kotlin/com/itsaky/androidide/plugins/aiagentlocal/plugin/LocalLlmPlugin.kt index 8b6a29e9..40c39d71 100644 --- a/plugins/AI-Agent-Local/src/main/kotlin/com/itsaky/androidide/plugins/aiagentlocal/plugin/LocalLlmPlugin.kt +++ b/plugins/AI-Agent-Local/src/main/kotlin/com/itsaky/androidide/plugins/aiagentlocal/plugin/LocalLlmPlugin.kt @@ -14,12 +14,6 @@ import com.itsaky.androidide.plugins.extensions.PluginTooltipButton import com.itsaky.androidide.plugins.extensions.PluginTooltipEntry import com.itsaky.androidide.plugins.services.LlmInferenceService import com.itsaky.androidide.plugins.services.SharedServices -import kotlinx.coroutines.CancellationException -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.ExperimentalCoroutinesApi -import kotlinx.coroutines.SupervisorJob -import kotlinx.coroutines.cancel /** * Registers the on-device llama.cpp backend with AI Core's inference router. @@ -42,9 +36,6 @@ class LocalLlmPlugin : IPlugin, DocumentationExtension { /** True once [backend] is registered with the router, so re-registration is idempotent. */ @Volatile private var registered = false - /** Runs the prompt-config load while the plugin is active; cancelled on deactivation. */ - @Volatile private var configScope: CoroutineScope? = null - companion object { const val PLUGIN_ID = "com.itsaky.androidide.plugins.aiagentlocal" @@ -182,22 +173,13 @@ class LocalLlmPlugin : IPlugin, DocumentationExtension { } /** Reads and validates the prompt config now, so building a prompt does no disk I/O. */ - @OptIn(ExperimentalCoroutinesApi::class) private fun preloadPromptConfig() { - releasePromptConfig() - val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default) - configScope = scope val source = AssetPromptConfigSource(context.androidContext.assets) - val load = sharedPromptConfig.preload(scope, source) - load.invokeOnCompletion { error -> - when (error) { - null -> reportLoadedConfig(load.getCompleted()) - is CancellationException -> Unit - else -> context.logger.error( - "LocalLlmPlugin: prompt config failed to load; ai-core's default prompt is sent instead", - error, - ) - } + sharedPromptConfig.reload(source, ::reportLoadedConfig) { error -> + context.logger.error( + "LocalLlmPlugin: prompt config failed to load; ai-core's default prompt is sent instead", + error, + ) } } @@ -213,13 +195,6 @@ class LocalLlmPlugin : IPlugin, DocumentationExtension { } } - /** Drops the cached config and stops a load still in flight. Idempotent. */ - private fun releasePromptConfig() { - sharedPromptConfig.clear() - configScope?.cancel() - configScope = null - } - override fun deactivate(): Boolean { context.logger.info("LocalLlmPlugin: Deactivating plugin") @@ -235,7 +210,7 @@ class LocalLlmPlugin : IPlugin, DocumentationExtension { // A disabled plugin must not keep the loaded model resident in host RAM. releaseBackend() - releasePromptConfig() + sharedPromptConfig.clear() true } catch (e: Exception) { @@ -262,7 +237,7 @@ class LocalLlmPlugin : IPlugin, DocumentationExtension { runCatching { context.removePluginLifecycleListener(aiCoreLifecycle) } releaseBackend() - releasePromptConfig() + sharedPromptConfig.clear() pluginContext = null context.logger.info("LocalLlmPlugin: Released local LLM backend") } diff --git a/plugins/AI-Agent-OpenAI/src/main/kotlin/com/itsaky/androidide/plugins/aiagentopenai/plugin/OpenAiPlugin.kt b/plugins/AI-Agent-OpenAI/src/main/kotlin/com/itsaky/androidide/plugins/aiagentopenai/plugin/OpenAiPlugin.kt index 2a6e7882..a960720f 100644 --- a/plugins/AI-Agent-OpenAI/src/main/kotlin/com/itsaky/androidide/plugins/aiagentopenai/plugin/OpenAiPlugin.kt +++ b/plugins/AI-Agent-OpenAI/src/main/kotlin/com/itsaky/androidide/plugins/aiagentopenai/plugin/OpenAiPlugin.kt @@ -13,12 +13,6 @@ import com.itsaky.androidide.plugins.extensions.PluginTooltipButton import com.itsaky.androidide.plugins.extensions.PluginTooltipEntry import com.itsaky.androidide.plugins.services.LlmInferenceService import com.itsaky.androidide.plugins.services.SharedServices -import kotlinx.coroutines.CancellationException -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.ExperimentalCoroutinesApi -import kotlinx.coroutines.SupervisorJob -import kotlinx.coroutines.cancel /** * Registers the OpenAI-compatible backend with AI Core's inference router. @@ -35,9 +29,6 @@ class OpenAiPlugin : IPlugin, DocumentationExtension { /** True once [backend] is registered with the router, so re-registration is idempotent. */ @Volatile private var registered = false - /** Runs the prompt-config load while the plugin is active; cancelled on deactivation. */ - @Volatile private var configScope: CoroutineScope? = null - companion object { const val PLUGIN_ID = "com.itsaky.androidide.plugins.aiagentopenai" @@ -180,22 +171,13 @@ class OpenAiPlugin : IPlugin, DocumentationExtension { } /** Reads and validates the prompt config now, so building a prompt does no disk I/O. */ - @OptIn(ExperimentalCoroutinesApi::class) private fun preloadPromptConfig() { - releasePromptConfig() - val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default) - configScope = scope val source = AssetPromptConfigSource(context.androidContext.assets) - val load = sharedPromptConfig.preload(scope, source) - load.invokeOnCompletion { error -> - when (error) { - null -> reportLoadedConfig(load.getCompleted()) - is CancellationException -> Unit - else -> context.logger.error( - "OpenAiPlugin: prompt config failed to load; ai-core's default prompt is sent instead", - error, - ) - } + sharedPromptConfig.reload(source, ::reportLoadedConfig) { error -> + context.logger.error( + "OpenAiPlugin: prompt config failed to load; ai-core's default prompt is sent instead", + error, + ) } } @@ -211,13 +193,6 @@ class OpenAiPlugin : IPlugin, DocumentationExtension { } } - /** Drops the cached config and stops a load still in flight. Idempotent. */ - private fun releasePromptConfig() { - sharedPromptConfig.clear() - configScope?.cancel() - configScope = null - } - override fun deactivate(): Boolean { context.logger.info("OpenAiPlugin: Deactivating plugin") @@ -233,7 +208,7 @@ class OpenAiPlugin : IPlugin, DocumentationExtension { // A disabled plugin must not keep the decrypted key on the host heap. releaseBackend() - releasePromptConfig() + sharedPromptConfig.clear() true } catch (e: Exception) { @@ -260,7 +235,7 @@ class OpenAiPlugin : IPlugin, DocumentationExtension { runCatching { context.removePluginLifecycleListener(aiCoreLifecycle) } releaseBackend() - releasePromptConfig() + sharedPromptConfig.clear() pluginContext = null context.logger.info("OpenAiPlugin: Released OpenAI backend") } diff --git a/plugins/AI-Core/src/main/kotlin/com/itsaky/androidide/plugins/aicore/plugin/AiCorePlugin.kt b/plugins/AI-Core/src/main/kotlin/com/itsaky/androidide/plugins/aicore/plugin/AiCorePlugin.kt index 0f3ce913..2f325786 100644 --- a/plugins/AI-Core/src/main/kotlin/com/itsaky/androidide/plugins/aicore/plugin/AiCorePlugin.kt +++ b/plugins/AI-Core/src/main/kotlin/com/itsaky/androidide/plugins/aicore/plugin/AiCorePlugin.kt @@ -29,21 +29,12 @@ import com.itsaky.androidide.plugins.services.LlmInferenceService import com.itsaky.androidide.plugins.services.SharedServices import com.itsaky.androidide.plugins.services.ToolSourceRegistry import java.io.File -import kotlinx.coroutines.CancellationException -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.ExperimentalCoroutinesApi -import kotlinx.coroutines.SupervisorJob -import kotlinx.coroutines.cancel class AiCorePlugin : IPlugin, UIExtension, DocumentationExtension, SettingsExtension { private lateinit var context: PluginContext private var llmService: LlmInferenceService? = null - /** Runs this plugin's background work while it is active; cancelled on deactivation. */ - private var activeScope: CoroutineScope? = null - companion object { /** Must match `plugin.id` in AndroidManifest.xml — keys the host's plugin Context lookup * used by [com.itsaky.androidide.plugins.base.PluginFragmentHelper.getPluginInflater]. */ @@ -167,18 +158,10 @@ class AiCorePlugin : IPlugin, UIExtension, DocumentationExtension, SettingsExten } /** Reads and validates the prompt config now, so the first chat turn does no disk I/O. */ - @OptIn(ExperimentalCoroutinesApi::class) private fun preloadPromptConfig() { - val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default) - activeScope = scope val source = AssetPromptConfigSource(context.androidContext.assets) - val load = sharedPromptConfig.preload(scope, source) - load.invokeOnCompletion { error -> - when (error) { - null -> reportLoadedConfig(load.getCompleted()) - is CancellationException -> Unit - else -> context.logger.error("AI Core Plugin: prompt config failed to load", error) - } + sharedPromptConfig.reload(source, ::reportLoadedConfig) { error -> + context.logger.error("AI Core Plugin: prompt config failed to load", error) } } @@ -199,8 +182,6 @@ class AiCorePlugin : IPlugin, UIExtension, DocumentationExtension, SettingsExten context.logger.info("AI Core Plugin deactivating...") sharedPromptConfig.clear() - activeScope?.cancel() - activeScope = null // Backends belong to their own plugins; dropping the service drops the whole registry, and // each backend plugin unregisters itself on its own deactivation. From 5805146819c65b40c2071097944b138785cb7a94 Mon Sep 17 00:00:00 2001 From: John Trujillo Date: Thu, 1 Oct 2026 09:32:18 -0500 Subject: [PATCH 4/4] fix(ai-agent): address PR #114 re-review Refs: ADFA-6223 --- .../aiagentgemini/backend/GeminiToolProtocol.kt | 4 +--- .../aiagentgemini/backend/GeminiWebSearch.kt | 4 +--- .../aiagentlocal/backend/LocalLlmBackend.kt | 8 +------- .../aiagentopenai/backend/OpenAiToolProtocol.kt | 4 +--- .../aiagentopenai/backend/OpenAiWebSearch.kt | 4 +--- .../src/main/assets/prompts/ide_context.yml | 11 ++++++----- .../AI-Core/src/main/assets/prompts/layout.yml | 3 +++ .../plugins/aicore/prompt/PromptVariables.kt | 6 ++++++ .../plugins/aicore/prompt/SessionContext.kt | 2 ++ .../aicore/prompt/SystemPromptFactory.kt | 3 ++- .../aicore/prompt/SystemPromptRenderer.kt | 2 +- .../plugins/aicore/prompt/ToolResultsPrompt.kt | 8 ++++---- .../aicore/prompt/config/AgentPromptConfig.kt | 4 +++- .../prompt/config/AgentPromptConfigParser.kt | 1 + .../plugins/aicore/tool/ToolCallExtractor.kt | 4 ++-- .../aicore/tool/handlers/FetchUrlHandler.kt | 2 ++ .../plugins/aicore/tool/web/BackendWebSearch.kt | 3 ++- .../aicore/tool/web/VerificationPolicy.kt | 5 +++-- .../plugins/aicore/tool/web/WebAccess.kt | 13 ------------- .../aicore/prompt/IdeContextBlockTest.kt | 17 ++++++++++++++--- .../aicore/prompt/SystemPromptFactoryTest.kt | 5 +++-- .../aicore/prompt/ToolResultsPromptTest.kt | 9 +++++++++ .../aicore/tool/ToolCallExtractorTest.kt | 10 ++++++++++ .../aicore/tool/web/BackendWebSearchTest.kt | 3 ++- .../aicore/tool/web/VerificationPolicyTest.kt | 5 +++-- 25 files changed, 83 insertions(+), 57 deletions(-) diff --git a/plugins/AI-Agent-Gemini/src/main/kotlin/com/itsaky/androidide/plugins/aiagentgemini/backend/GeminiToolProtocol.kt b/plugins/AI-Agent-Gemini/src/main/kotlin/com/itsaky/androidide/plugins/aiagentgemini/backend/GeminiToolProtocol.kt index ca65c5d9..241a184a 100644 --- a/plugins/AI-Agent-Gemini/src/main/kotlin/com/itsaky/androidide/plugins/aiagentgemini/backend/GeminiToolProtocol.kt +++ b/plugins/AI-Agent-Gemini/src/main/kotlin/com/itsaky/androidide/plugins/aiagentgemini/backend/GeminiToolProtocol.kt @@ -2,6 +2,7 @@ package com.itsaky.androidide.plugins.aiagentgemini.backend import com.itsaky.androidide.plugins.services.LlmInferenceService.LlmConfig import com.itsaky.androidide.plugins.services.LlmInferenceService.ToolCallRequest +import com.itsaky.androidide.plugins.services.LlmInferenceService.ToolCallingBackend.EXTRA_PARAM_REQUIRED_TOOL import com.itsaky.androidide.plugins.services.LlmInferenceService.ToolDefinition import org.json.JSONArray import org.json.JSONObject @@ -23,9 +24,6 @@ internal object GeminiToolProtocol { /** Appended when an object argument has to be declared as JSON text; see [declarable]. */ private const val AS_JSON_TEXT = " Written as a JSON object." - /** The key ai-core's `WebAccess.EXTRA_PARAM_REQUIRED_TOOL` sets; the same literal on both sides. */ - const val EXTRA_PARAM_REQUIRED_TOOL = "required_tool" - /** * The `toolConfig` that makes the model call [config]'s required tool this turn. * diff --git a/plugins/AI-Agent-Gemini/src/main/kotlin/com/itsaky/androidide/plugins/aiagentgemini/backend/GeminiWebSearch.kt b/plugins/AI-Agent-Gemini/src/main/kotlin/com/itsaky/androidide/plugins/aiagentgemini/backend/GeminiWebSearch.kt index a9de1449..64dbbdf3 100644 --- a/plugins/AI-Agent-Gemini/src/main/kotlin/com/itsaky/androidide/plugins/aiagentgemini/backend/GeminiWebSearch.kt +++ b/plugins/AI-Agent-Gemini/src/main/kotlin/com/itsaky/androidide/plugins/aiagentgemini/backend/GeminiWebSearch.kt @@ -1,6 +1,7 @@ package com.itsaky.androidide.plugins.aiagentgemini.backend import com.itsaky.androidide.plugins.services.LlmInferenceService.LlmConfig +import com.itsaky.androidide.plugins.services.LlmInferenceService.WebSearchBackend.EXTRA_PARAM_WEB_SEARCH import org.json.JSONArray import org.json.JSONObject @@ -11,9 +12,6 @@ import org.json.JSONObject */ internal object GeminiWebSearch { - /** The key ai-core's `WebAccess.EXTRA_PARAM_WEB_SEARCH` sets; the same literal on both sides. */ - const val EXTRA_PARAM_WEB_SEARCH = "web_search" - /** * Put after a report the output cap cut short. Without it the agent read a report ending * mid-sentence as complete, and wrote a placeholder for the version the cut had dropped. diff --git a/plugins/AI-Agent-Local/src/main/kotlin/com/itsaky/androidide/plugins/aiagentlocal/backend/LocalLlmBackend.kt b/plugins/AI-Agent-Local/src/main/kotlin/com/itsaky/androidide/plugins/aiagentlocal/backend/LocalLlmBackend.kt index dd7a5e86..0590d949 100644 --- a/plugins/AI-Agent-Local/src/main/kotlin/com/itsaky/androidide/plugins/aiagentlocal/backend/LocalLlmBackend.kt +++ b/plugins/AI-Agent-Local/src/main/kotlin/com/itsaky/androidide/plugins/aiagentlocal/backend/LocalLlmBackend.kt @@ -70,12 +70,6 @@ class LocalLlmBackend( */ const val EXTRA_PARAM_GRAMMAR = "grammar" - /** - * `extraParams` key ai-core's `web_search` tool sets to ask for an answer from a web - * search, which an on-device model cannot make; see [generate]. - */ - const val EXTRA_PARAM_WEB_SEARCH = "web_search" - /** What the agent is told when it asks for a search; it can still read a page with fetch_url. */ private const val WEB_SEARCH_UNSUPPORTED = "Web search is not available with the on-device model. Use fetch_url to read a page instead." @@ -713,7 +707,7 @@ class LocalLlmBackend( override fun generate(prompt: String, config: LlmConfig): CompletableFuture { context.logger.info("LocalLlmBackend.generate() called with prompt: ${prompt.take(50)}...") // Refused, not answered: a search reply made up from the model's memory reads as found fact. - if (config.extraParams?.get(EXTRA_PARAM_WEB_SEARCH) == true) { + if (config.extraParams?.get(WebSearchBackend.EXTRA_PARAM_WEB_SEARCH) == true) { return CompletableFuture.completedFuture(LlmResponse.failure(WEB_SEARCH_UNSUPPORTED)) } return runGeneration(buildPrompt(config.systemPrompt, prompt), config) diff --git a/plugins/AI-Agent-OpenAI/src/main/kotlin/com/itsaky/androidide/plugins/aiagentopenai/backend/OpenAiToolProtocol.kt b/plugins/AI-Agent-OpenAI/src/main/kotlin/com/itsaky/androidide/plugins/aiagentopenai/backend/OpenAiToolProtocol.kt index 968e8bbd..f1f38333 100644 --- a/plugins/AI-Agent-OpenAI/src/main/kotlin/com/itsaky/androidide/plugins/aiagentopenai/backend/OpenAiToolProtocol.kt +++ b/plugins/AI-Agent-OpenAI/src/main/kotlin/com/itsaky/androidide/plugins/aiagentopenai/backend/OpenAiToolProtocol.kt @@ -2,6 +2,7 @@ package com.itsaky.androidide.plugins.aiagentopenai.backend import com.itsaky.androidide.plugins.services.LlmInferenceService.LlmConfig import com.itsaky.androidide.plugins.services.LlmInferenceService.ToolCallRequest +import com.itsaky.androidide.plugins.services.LlmInferenceService.ToolCallingBackend.EXTRA_PARAM_REQUIRED_TOOL import com.itsaky.androidide.plugins.services.LlmInferenceService.ToolDefinition import org.json.JSONArray import org.json.JSONObject @@ -20,9 +21,6 @@ internal object OpenAiToolProtocol { */ private const val MAX_SCHEMA_DEPTH = 12 - /** The key ai-core's `WebAccess.EXTRA_PARAM_REQUIRED_TOOL` sets; the same literal on both sides. */ - const val EXTRA_PARAM_REQUIRED_TOOL = "required_tool" - /** * The `tool_choice` that makes the model call [config]'s required tool this turn. * diff --git a/plugins/AI-Agent-OpenAI/src/main/kotlin/com/itsaky/androidide/plugins/aiagentopenai/backend/OpenAiWebSearch.kt b/plugins/AI-Agent-OpenAI/src/main/kotlin/com/itsaky/androidide/plugins/aiagentopenai/backend/OpenAiWebSearch.kt index 6351cdb5..38ee5644 100644 --- a/plugins/AI-Agent-OpenAI/src/main/kotlin/com/itsaky/androidide/plugins/aiagentopenai/backend/OpenAiWebSearch.kt +++ b/plugins/AI-Agent-OpenAI/src/main/kotlin/com/itsaky/androidide/plugins/aiagentopenai/backend/OpenAiWebSearch.kt @@ -2,6 +2,7 @@ package com.itsaky.androidide.plugins.aiagentopenai.backend import com.itsaky.androidide.plugins.aiagentopenai.errors.OpenAiReplyException import com.itsaky.androidide.plugins.services.LlmInferenceService.LlmConfig +import com.itsaky.androidide.plugins.services.LlmInferenceService.WebSearchBackend.EXTRA_PARAM_WEB_SEARCH import org.json.JSONArray import org.json.JSONObject @@ -12,9 +13,6 @@ import org.json.JSONObject */ internal object OpenAiWebSearch { - /** The key ai-core's `WebAccess.EXTRA_PARAM_WEB_SEARCH` sets; the same literal on both sides. */ - const val EXTRA_PARAM_WEB_SEARCH = "web_search" - /** The Responses API endpoint, under the same base URL as `chat/completions`. */ const val RESPONSES_PATH = "/responses" diff --git a/plugins/AI-Core/src/main/assets/prompts/ide_context.yml b/plugins/AI-Core/src/main/assets/prompts/ide_context.yml index f821b0c3..89bbd48b 100644 --- a/plugins/AI-Core/src/main/assets/prompts/ide_context.yml +++ b/plugins/AI-Core/src/main/assets/prompts/ide_context.yml @@ -16,9 +16,10 @@ ide_context: # today's date, and one never told it can reach the web claims it cannot. session: current_time: "Current date and time on the user's device: {{CURRENT_TIME}}" - web_access: >- + # Stated only to a backend that offers web_search. + web_search: >- You can reach the internet. For anything current or outside what you know — news, releases, - prices, documentation, a library's latest version — call web_search, and call fetch_url to - read a page, a repository or a file the user links. Your training data is older than today, so - check with web_search before you judge code or name a library's API or version. Never say you - cannot access the internet. + prices, documentation, a library's latest version — call web_search. Your training data is + older than today, so check with web_search before you judge code or name a library's API or + version. Never say you cannot access the internet. + web_access: Call fetch_url to read a page, a repository or a file the user links. diff --git a/plugins/AI-Core/src/main/assets/prompts/layout.yml b/plugins/AI-Core/src/main/assets/prompts/layout.yml index d331d9f9..7babab8a 100644 --- a/plugins/AI-Core/src/main/assets/prompts/layout.yml +++ b/plugins/AI-Core/src/main/assets/prompts/layout.yml @@ -32,6 +32,9 @@ layout: # Also appended to a backend's own prompt, so the session lines reach every backend. ide_context: | {{SESSION_CURRENT_TIME}} + {{#CAN_SEARCH_WEB}} + {{SESSION_WEB_SEARCH}} + {{/CAN_SEARCH_WEB}} {{SESSION_WEB_ACCESS}} {{#HAS_IDE_CONTEXT}} diff --git a/plugins/AI-Core/src/main/kotlin/com/itsaky/androidide/plugins/aicore/prompt/PromptVariables.kt b/plugins/AI-Core/src/main/kotlin/com/itsaky/androidide/plugins/aicore/prompt/PromptVariables.kt index 9c5243e8..364e2853 100644 --- a/plugins/AI-Core/src/main/kotlin/com/itsaky/androidide/plugins/aicore/prompt/PromptVariables.kt +++ b/plugins/AI-Core/src/main/kotlin/com/itsaky/androidide/plugins/aicore/prompt/PromptVariables.kt @@ -29,6 +29,7 @@ object PromptVariables { const val IDE_CONTEXT_MODULES_KNOWN = "IDE_CONTEXT_MODULES_KNOWN" const val IDE_CONTEXT_CLOSING = "IDE_CONTEXT_CLOSING" const val SESSION_CURRENT_TIME = "SESSION_CURRENT_TIME" + const val SESSION_WEB_SEARCH = "SESSION_WEB_SEARCH" const val SESSION_WEB_ACCESS = "SESSION_WEB_ACCESS" const val LAYOUT_IDE_CONTEXT = "LAYOUT_IDE_CONTEXT" const val AGENT_LOOP_GROUNDING = "AGENT_LOOP_GROUNDING" @@ -106,6 +107,9 @@ object PromptVariables { /** The device's date, time and time zone; see [SessionContext]. */ const val CURRENT_TIME = "CURRENT_TIME" + /** Whether this run offers web_search; see [SessionContext]. */ + const val CAN_SEARCH_WEB = "CAN_SEARCH_WEB" + /** Whether the IDE has anything open or any module worth stating. */ const val HAS_IDE_CONTEXT = "HAS_IDE_CONTEXT" @@ -221,7 +225,9 @@ object PromptVariables { val text = config.ideContext return mapOf( SESSION_CURRENT_TIME to config.session.currentTime, + SESSION_WEB_SEARCH to config.session.webSearch, SESSION_WEB_ACCESS to config.session.webAccess, + CAN_SEARCH_WEB to session.canSearchWeb, CURRENT_TIME to session.currentTime, IDE_CONTEXT_HEADING to text.heading, IDE_CONTEXT_CURRENT_FILE to text.currentFile, diff --git a/plugins/AI-Core/src/main/kotlin/com/itsaky/androidide/plugins/aicore/prompt/SessionContext.kt b/plugins/AI-Core/src/main/kotlin/com/itsaky/androidide/plugins/aicore/prompt/SessionContext.kt index b4b9a672..15d6d253 100644 --- a/plugins/AI-Core/src/main/kotlin/com/itsaky/androidide/plugins/aicore/prompt/SessionContext.kt +++ b/plugins/AI-Core/src/main/kotlin/com/itsaky/androidide/plugins/aicore/prompt/SessionContext.kt @@ -10,9 +10,11 @@ import java.util.Locale * real-time information" even to a question it has a tool for (ADFA-6223). * * @property currentTime the device's date, time and time zone, already worded for the prompt. + * @property canSearchWeb whether this run offers web_search; told otherwise, a model calls it anyway. */ data class SessionContext( val currentTime: String, + val canSearchWeb: Boolean = false, ) { companion object { diff --git a/plugins/AI-Core/src/main/kotlin/com/itsaky/androidide/plugins/aicore/prompt/SystemPromptFactory.kt b/plugins/AI-Core/src/main/kotlin/com/itsaky/androidide/plugins/aicore/prompt/SystemPromptFactory.kt index 022a8ea6..2f3f3950 100644 --- a/plugins/AI-Core/src/main/kotlin/com/itsaky/androidide/plugins/aicore/prompt/SystemPromptFactory.kt +++ b/plugins/AI-Core/src/main/kotlin/com/itsaky/androidide/plugins/aicore/prompt/SystemPromptFactory.kt @@ -2,6 +2,7 @@ package com.itsaky.androidide.plugins.aicore.prompt import com.itsaky.androidide.plugins.ai.prompt.PromptConfigProvider import com.itsaky.androidide.plugins.aicore.prompt.config.AgentPromptConfig +import com.itsaky.androidide.plugins.aicore.tool.web.WebAccess import com.itsaky.androidide.plugins.services.LlmInferenceService.SystemPromptRequest import com.itsaky.androidide.plugins.services.LlmInferenceService.ToolDefinition @@ -35,7 +36,7 @@ class SystemPromptFactory( val loaded = config.config() // One editor read serves both the IDE CONTEXT block and the paths in the examples. val context = ideContext.read() - val session = session() + val session = session().copy(canSearchWeb = tools.any { it.name == WebAccess.WEB_SEARCH_TOOL }) val request = SystemPromptRequest( tools, // Null tells the backend this side parses no envelope; see SystemPromptRequest. diff --git a/plugins/AI-Core/src/main/kotlin/com/itsaky/androidide/plugins/aicore/prompt/SystemPromptRenderer.kt b/plugins/AI-Core/src/main/kotlin/com/itsaky/androidide/plugins/aicore/prompt/SystemPromptRenderer.kt index df29a812..9547e131 100644 --- a/plugins/AI-Core/src/main/kotlin/com/itsaky/androidide/plugins/aicore/prompt/SystemPromptRenderer.kt +++ b/plugins/AI-Core/src/main/kotlin/com/itsaky/androidide/plugins/aicore/prompt/SystemPromptRenderer.kt @@ -82,7 +82,7 @@ object SystemPromptRenderer { Triple( SystemPromptRequest(tools, "…", "app/Main.kt"), IdeContext("app/Main.kt", listOf("lib/A.kt", "lib/B.kt"), listOf(full, bare)), - session, + session.copy(canSearchWeb = true), ), Triple(SystemPromptRequest(emptyList(), null, null), IdeContext(null, emptyList(), listOf(bare)), session), Triple(SystemPromptRequest(emptyList(), null, null), IdeContext.EMPTY, session), diff --git a/plugins/AI-Core/src/main/kotlin/com/itsaky/androidide/plugins/aicore/prompt/ToolResultsPrompt.kt b/plugins/AI-Core/src/main/kotlin/com/itsaky/androidide/plugins/aicore/prompt/ToolResultsPrompt.kt index 9ceee4ae..9bf458b7 100644 --- a/plugins/AI-Core/src/main/kotlin/com/itsaky/androidide/plugins/aicore/prompt/ToolResultsPrompt.kt +++ b/plugins/AI-Core/src/main/kotlin/com/itsaky/androidide/plugins/aicore/prompt/ToolResultsPrompt.kt @@ -37,8 +37,8 @@ class ToolResultsPrompt( const val DEFAULT_CHAR_LIMIT = 4000 /** - * A search report's cap: [DEFAULT_CHAR_LIMIT] cut a full report before the Sources list the - * backend appends to it, leaving the agent nothing to cite. No local backend can search. + * A search report's or fetched page's cap: [DEFAULT_CHAR_LIMIT] cut a report before its Sources + * list, and a page before anything past its navigation. */ const val WEB_SEARCH_CHAR_LIMIT = 12000 @@ -47,7 +47,7 @@ class ToolResultsPrompt( * * @param config the loaded prompt config. * @param terminalTool the name of the tool that ends a run by answering the user. - * @param charLimit each result's cap; a web search's is at least [WEB_SEARCH_CHAR_LIMIT]. + * @param charLimit each result's cap; a web search's or fetch's is at least [WEB_SEARCH_CHAR_LIMIT]. * @param calls the tool calls that ran. * @param results their results, positionally aligned with [calls]. * @return the turn to add to the transcript. @@ -63,7 +63,7 @@ class ToolResultsPrompt( results.forEachIndexed { index, result -> val name = calls.getOrNull(index)?.name ?: "tool" val limit = - if (name == WebAccess.WEB_SEARCH_TOOL) maxOf(charLimit, WEB_SEARCH_CHAR_LIMIT) else charLimit + if (name == WebAccess.WEB_SEARCH_TOOL || name == WebAccess.FETCH_URL_TOOL) maxOf(charLimit, WEB_SEARCH_CHAR_LIMIT) else charLimit val body = truncate(config, terminalTool, body(config, terminalTool, result), limit) append("\n[").append(name).append("] ").append(body) append("\n\n\n") diff --git a/plugins/AI-Core/src/main/kotlin/com/itsaky/androidide/plugins/aicore/prompt/config/AgentPromptConfig.kt b/plugins/AI-Core/src/main/kotlin/com/itsaky/androidide/plugins/aicore/prompt/config/AgentPromptConfig.kt index 226ef28d..e173dc09 100644 --- a/plugins/AI-Core/src/main/kotlin/com/itsaky/androidide/plugins/aicore/prompt/config/AgentPromptConfig.kt +++ b/plugins/AI-Core/src/main/kotlin/com/itsaky/androidide/plugins/aicore/prompt/config/AgentPromptConfig.kt @@ -78,10 +78,12 @@ data class AgentPromptConfig( /** * @property currentTime states the device's date and time. - * @property webAccess says the web tools are there to be used. + * @property webSearch says web_search is there to be used, for a backend that offers it. + * @property webAccess says fetch_url is there to be used. */ data class SessionText( val currentTime: PromptText, + val webSearch: PromptText, val webAccess: PromptText, ) diff --git a/plugins/AI-Core/src/main/kotlin/com/itsaky/androidide/plugins/aicore/prompt/config/AgentPromptConfigParser.kt b/plugins/AI-Core/src/main/kotlin/com/itsaky/androidide/plugins/aicore/prompt/config/AgentPromptConfigParser.kt index d397714b..96490857 100644 --- a/plugins/AI-Core/src/main/kotlin/com/itsaky/androidide/plugins/aicore/prompt/config/AgentPromptConfigParser.kt +++ b/plugins/AI-Core/src/main/kotlin/com/itsaky/androidide/plugins/aicore/prompt/config/AgentPromptConfigParser.kt @@ -61,6 +61,7 @@ object AgentPromptConfigParser : PromptConfigParser { session = obj("session").read { SessionText( currentTime = text("current_time"), + webSearch = text("web_search"), webAccess = text("web_access"), ) }, diff --git a/plugins/AI-Core/src/main/kotlin/com/itsaky/androidide/plugins/aicore/tool/ToolCallExtractor.kt b/plugins/AI-Core/src/main/kotlin/com/itsaky/androidide/plugins/aicore/tool/ToolCallExtractor.kt index 15ec64eb..4b049e86 100644 --- a/plugins/AI-Core/src/main/kotlin/com/itsaky/androidide/plugins/aicore/tool/ToolCallExtractor.kt +++ b/plugins/AI-Core/src/main/kotlin/com/itsaky/androidide/plugins/aicore/tool/ToolCallExtractor.kt @@ -48,12 +48,12 @@ class ToolCallExtractor { private val BARE_TOOL_KEY_REGEX = Regex(""""tool"\s*:""") /** - * A fenced code block, closed or left open by a reply that hit its output cap. + * A fenced code block, closed or, when it starts a line, left open by a reply that hit its cap. * * Only ever used to decide whether a `{"tool":…}` shape is a call or something the user * asked to be shown, never to produce text, so swallowing an unclosed tail is the safe way. */ - private val FENCED_BLOCK_REGEX = Regex("""```(?:.*?```|.*)""", RegexOption.DOT_MATCHES_ALL) + private val FENCED_BLOCK_REGEX = Regex("""```.*?```|(?m:^)```.*""", RegexOption.DOT_MATCHES_ALL) /** * Classifies a reply that [extractToolCalls] found nothing in. diff --git a/plugins/AI-Core/src/main/kotlin/com/itsaky/androidide/plugins/aicore/tool/handlers/FetchUrlHandler.kt b/plugins/AI-Core/src/main/kotlin/com/itsaky/androidide/plugins/aicore/tool/handlers/FetchUrlHandler.kt index 9edd2118..35d71585 100644 --- a/plugins/AI-Core/src/main/kotlin/com/itsaky/androidide/plugins/aicore/tool/handlers/FetchUrlHandler.kt +++ b/plugins/AI-Core/src/main/kotlin/com/itsaky/androidide/plugins/aicore/tool/handlers/FetchUrlHandler.kt @@ -33,6 +33,8 @@ class FetchUrlHandler : ToolHandler { required = listOf("url"), ) override val requiresApproval = true + // Always Allow would approve every later host too, which the cross-host hand-back exists to stop. + override val allowsSessionApproval = false override val argAliases = mapOf("link" to "url", "address" to "url") override suspend fun validate(args: Map): Validation { diff --git a/plugins/AI-Core/src/main/kotlin/com/itsaky/androidide/plugins/aicore/tool/web/BackendWebSearch.kt b/plugins/AI-Core/src/main/kotlin/com/itsaky/androidide/plugins/aicore/tool/web/BackendWebSearch.kt index ff1d3e3b..3266767a 100644 --- a/plugins/AI-Core/src/main/kotlin/com/itsaky/androidide/plugins/aicore/tool/web/BackendWebSearch.kt +++ b/plugins/AI-Core/src/main/kotlin/com/itsaky/androidide/plugins/aicore/tool/web/BackendWebSearch.kt @@ -8,6 +8,7 @@ import com.itsaky.androidide.plugins.aicore.prompt.SessionContext import com.itsaky.androidide.plugins.aicore.prompt.config.AgentPromptConfig import com.itsaky.androidide.plugins.services.LlmInferenceService.LlmBackend import com.itsaky.androidide.plugins.services.LlmInferenceService.LlmConfig +import com.itsaky.androidide.plugins.services.LlmInferenceService.WebSearchBackend.EXTRA_PARAM_WEB_SEARCH import kotlinx.coroutines.future.await /** @@ -41,7 +42,7 @@ class BackendWebSearch( maxTokens = SEARCH_MAX_TOKENS systemPrompt = instruction(config.config(), currentTime()) // A backend that cannot search must refuse on seeing this, not answer from memory. - extraParams = mapOf(WebAccess.EXTRA_PARAM_WEB_SEARCH to true) + extraParams = mapOf(EXTRA_PARAM_WEB_SEARCH to true) } val response = backend.generate(query, request).await() if (!response.success) { diff --git a/plugins/AI-Core/src/main/kotlin/com/itsaky/androidide/plugins/aicore/tool/web/VerificationPolicy.kt b/plugins/AI-Core/src/main/kotlin/com/itsaky/androidide/plugins/aicore/tool/web/VerificationPolicy.kt index d15e27da..addb094f 100644 --- a/plugins/AI-Core/src/main/kotlin/com/itsaky/androidide/plugins/aicore/tool/web/VerificationPolicy.kt +++ b/plugins/AI-Core/src/main/kotlin/com/itsaky/androidide/plugins/aicore/tool/web/VerificationPolicy.kt @@ -1,6 +1,7 @@ package com.itsaky.androidide.plugins.aicore.tool.web import com.itsaky.androidide.plugins.services.LlmInferenceService.LlmConfig +import com.itsaky.androidide.plugins.services.LlmInferenceService.ToolCallingBackend.EXTRA_PARAM_REQUIRED_TOOL /** * Decides, from the message rather than the model's confidence, when a run must search before it @@ -24,7 +25,7 @@ object VerificationPolicy { * * @param config the run's config. * @param tool the tool the model must call. - * @return a new config, identical but for [WebAccess.EXTRA_PARAM_REQUIRED_TOOL]. + * @return a new config, identical but for [EXTRA_PARAM_REQUIRED_TOOL]. */ fun requiring(config: LlmConfig, tool: String): LlmConfig = LlmConfig(config.backendId).apply { modelName = config.modelName @@ -32,7 +33,7 @@ object VerificationPolicy { maxTokens = config.maxTokens stopSequences = config.stopSequences systemPrompt = config.systemPrompt - extraParams = config.extraParams.orEmpty() + (WebAccess.EXTRA_PARAM_REQUIRED_TOOL to tool) + extraParams = config.extraParams.orEmpty() + (EXTRA_PARAM_REQUIRED_TOOL to tool) } /** A fenced block, or at least [MIN_CODE_LINES] lines that read as source or build script. */ diff --git a/plugins/AI-Core/src/main/kotlin/com/itsaky/androidide/plugins/aicore/tool/web/WebAccess.kt b/plugins/AI-Core/src/main/kotlin/com/itsaky/androidide/plugins/aicore/tool/web/WebAccess.kt index 7fd16fea..b4c57a63 100644 --- a/plugins/AI-Core/src/main/kotlin/com/itsaky/androidide/plugins/aicore/tool/web/WebAccess.kt +++ b/plugins/AI-Core/src/main/kotlin/com/itsaky/androidide/plugins/aicore/tool/web/WebAccess.kt @@ -7,19 +7,6 @@ package com.itsaky.androidide.plugins.aicore.tool.web */ object WebAccess { - /** - * [com.itsaky.androidide.plugins.services.LlmInferenceService.LlmConfig.extraParams] key asking a - * backend to answer from a web search. Every backend in this repository reads the same literal. - */ - const val EXTRA_PARAM_WEB_SEARCH = "web_search" - - /** - * [com.itsaky.androidide.plugins.services.LlmInferenceService.LlmConfig.extraParams] key naming - * the one declared tool the model must call this turn; see [VerificationPolicy]. A backend that - * cannot force a call ignores it, and the agent loop asks for the call instead. - */ - const val EXTRA_PARAM_REQUIRED_TOOL = "required_tool" - const val WEB_SEARCH_TOOL = "web_search" const val FETCH_URL_TOOL = "fetch_url" diff --git a/plugins/AI-Core/src/test/kotlin/com/itsaky/androidide/plugins/aicore/prompt/IdeContextBlockTest.kt b/plugins/AI-Core/src/test/kotlin/com/itsaky/androidide/plugins/aicore/prompt/IdeContextBlockTest.kt index 7359d2df..fd62279b 100644 --- a/plugins/AI-Core/src/test/kotlin/com/itsaky/androidide/plugins/aicore/prompt/IdeContextBlockTest.kt +++ b/plugins/AI-Core/src/test/kotlin/com/itsaky/androidide/plugins/aicore/prompt/IdeContextBlockTest.kt @@ -27,14 +27,25 @@ class IdeContextBlockTest { } @Test - fun givenAnyRun_whenRendering_thenTheWebToolsAreNamedAndRefusalIsForbidden() { - val block = SystemPromptRenderer.renderIdeContext(shippedConfig, IdeContext.EMPTY, SESSION) + fun givenARunThatCanSearch_whenRendering_thenTheWebToolsAreNamedAndRefusalIsForbidden() { + val session = SESSION.copy(canSearchWeb = true) + val block = SystemPromptRenderer.renderIdeContext(shippedConfig, IdeContext.EMPTY, session) assertTrue(block.contains("call web_search")) - assertTrue(block.contains("call fetch_url")) + assertTrue(block.contains("Call fetch_url")) assertTrue(block.contains("Never say you cannot access the internet.")) } + @Test + fun givenARunThatCannotSearch_whenRendering_thenOnlyFetchUrlIsNamed() { + // Told to call a tool it was not given, the model gets "Unknown tool" or fakes a search. + val block = SystemPromptRenderer.renderIdeContext(shippedConfig, IdeContext.EMPTY, SESSION) + + assertFalse(block.contains("web_search")) + assertFalse(block.contains("Never say you cannot access the internet.")) + assertTrue(block.contains("Call fetch_url")) + } + @Test fun givenOnlyAFocusedFile_whenRendering_thenNoModuleLineOrBlankLineAppears() { val block = facts(IdeContext("app/Main.kt", emptyList(), emptyList())) diff --git a/plugins/AI-Core/src/test/kotlin/com/itsaky/androidide/plugins/aicore/prompt/SystemPromptFactoryTest.kt b/plugins/AI-Core/src/test/kotlin/com/itsaky/androidide/plugins/aicore/prompt/SystemPromptFactoryTest.kt index d243f575..2a283bf7 100644 --- a/plugins/AI-Core/src/test/kotlin/com/itsaky/androidide/plugins/aicore/prompt/SystemPromptFactoryTest.kt +++ b/plugins/AI-Core/src/test/kotlin/com/itsaky/androidide/plugins/aicore/prompt/SystemPromptFactoryTest.kt @@ -61,8 +61,9 @@ class SystemPromptFactoryTest { } @Test - fun givenABackendWithItsOwnPrompt_whenCreating_thenItIsToldTheWebIsReachable() { - val prompt = runBlocking { factory(FakeBackend("I am Gemini.")).create(tools) } + fun givenABackendWithItsOwnPromptOfferingWebSearch_whenCreating_thenItIsToldTheWebIsReachable() { + val search = ToolDefinition("web_search", "Search the web.", emptyMap()) + val prompt = runBlocking { factory(FakeBackend("I am Gemini.")).create(tools + search) } assertTrue(prompt.contains("You can reach the internet.")) } diff --git a/plugins/AI-Core/src/test/kotlin/com/itsaky/androidide/plugins/aicore/prompt/ToolResultsPromptTest.kt b/plugins/AI-Core/src/test/kotlin/com/itsaky/androidide/plugins/aicore/prompt/ToolResultsPromptTest.kt index 68e59f68..755c8404 100644 --- a/plugins/AI-Core/src/test/kotlin/com/itsaky/androidide/plugins/aicore/prompt/ToolResultsPromptTest.kt +++ b/plugins/AI-Core/src/test/kotlin/com/itsaky/androidide/plugins/aicore/prompt/ToolResultsPromptTest.kt @@ -155,6 +155,15 @@ class ToolResultsPromptTest { assertTrue(turn.contains("- https://ktor.io/docs\n")) } + @Test + fun givenAFetchedPagePastTheDefaultCap_whenRendering_thenItIsNotCut() { + val page = "x".repeat(ToolResultsPrompt.DEFAULT_CHAR_LIMIT) + "END" + + val turn = render(listOf(ToolCall("fetch_url", emptyMap())), listOf(ToolResult.success("Fetched", page))) + + assertTrue(turn.contains("END\n")) + } + @Test fun givenAProjectResultPastTheDefaultCap_whenRendering_thenItIsStillCut() { val turn = render(openFile, listOf(ToolResult.success("x".repeat(ToolResultsPrompt.DEFAULT_CHAR_LIMIT + 10)))) diff --git a/plugins/AI-Core/src/test/kotlin/com/itsaky/androidide/plugins/aicore/tool/ToolCallExtractorTest.kt b/plugins/AI-Core/src/test/kotlin/com/itsaky/androidide/plugins/aicore/tool/ToolCallExtractorTest.kt index adfa9347..12da85ec 100644 --- a/plugins/AI-Core/src/test/kotlin/com/itsaky/androidide/plugins/aicore/tool/ToolCallExtractorTest.kt +++ b/plugins/AI-Core/src/test/kotlin/com/itsaky/androidide/plugins/aicore/tool/ToolCallExtractorTest.kt @@ -274,6 +274,16 @@ class ToolCallExtractorTest { assertTrue(ToolCallExtractor.extractToolCalls(reply).isEmpty()) } + @Test + fun givenAStrayInlineFenceBeforeABareCall_whenExtracting_thenTheCallRuns() { + val reply = "Wrap it in ``` fences.\n{\"tool\":\"read_file\",\"args\":{\"file_path\":\"A.kt\"}}" + + val calls = ToolCallExtractor.extractToolCalls(reply) + + assertEquals(1, calls.size) + assertEquals("read_file", calls[0].name) + } + @Test fun givenABareCallAfterAFencedExample_whenExtracting_thenOnlyTheCallOutsideRuns() { val reply = "```json\n{\"tool\":\"delete_file\",\"args\":{\"file_path\":\"A.kt\"}}\n```\n" + diff --git a/plugins/AI-Core/src/test/kotlin/com/itsaky/androidide/plugins/aicore/tool/web/BackendWebSearchTest.kt b/plugins/AI-Core/src/test/kotlin/com/itsaky/androidide/plugins/aicore/tool/web/BackendWebSearchTest.kt index e9233230..0ea8d068 100644 --- a/plugins/AI-Core/src/test/kotlin/com/itsaky/androidide/plugins/aicore/tool/web/BackendWebSearchTest.kt +++ b/plugins/AI-Core/src/test/kotlin/com/itsaky/androidide/plugins/aicore/tool/web/BackendWebSearchTest.kt @@ -4,6 +4,7 @@ import com.itsaky.androidide.plugins.aicore.prompt.config.DirectoryPromptConfigS import com.itsaky.androidide.plugins.services.LlmInferenceService.LlmBackend import com.itsaky.androidide.plugins.services.LlmInferenceService.LlmConfig import com.itsaky.androidide.plugins.services.LlmInferenceService.LlmResponse +import com.itsaky.androidide.plugins.services.LlmInferenceService.WebSearchBackend.EXTRA_PARAM_WEB_SEARCH import io.mockk.every import io.mockk.mockk import io.mockk.slot @@ -36,7 +37,7 @@ class BackendWebSearchTest { runBlocking { search(backend).search("latest kotlin version") } - assertEquals(true, sent.captured.extraParams?.get(WebAccess.EXTRA_PARAM_WEB_SEARCH)) + assertEquals(true, sent.captured.extraParams?.get(EXTRA_PARAM_WEB_SEARCH)) assertEquals("gemini", sent.captured.backendId) } diff --git a/plugins/AI-Core/src/test/kotlin/com/itsaky/androidide/plugins/aicore/tool/web/VerificationPolicyTest.kt b/plugins/AI-Core/src/test/kotlin/com/itsaky/androidide/plugins/aicore/tool/web/VerificationPolicyTest.kt index 4d307dbb..410e9011 100644 --- a/plugins/AI-Core/src/test/kotlin/com/itsaky/androidide/plugins/aicore/tool/web/VerificationPolicyTest.kt +++ b/plugins/AI-Core/src/test/kotlin/com/itsaky/androidide/plugins/aicore/tool/web/VerificationPolicyTest.kt @@ -1,6 +1,7 @@ package com.itsaky.androidide.plugins.aicore.tool.web import com.itsaky.androidide.plugins.services.LlmInferenceService.LlmConfig +import com.itsaky.androidide.plugins.services.LlmInferenceService.ToolCallingBackend.EXTRA_PARAM_REQUIRED_TOOL import org.junit.Assert.assertEquals import org.junit.Assert.assertFalse import org.junit.Assert.assertNull @@ -102,9 +103,9 @@ class VerificationPolicyTest { assertEquals(100, required.maxTokens) assertEquals("system", required.systemPrompt) assertEquals( - mapOf("grammar" to "g", WebAccess.EXTRA_PARAM_REQUIRED_TOOL to WebAccess.WEB_SEARCH_TOOL), + mapOf("grammar" to "g", EXTRA_PARAM_REQUIRED_TOOL to WebAccess.WEB_SEARCH_TOOL), required.extraParams, ) - assertNull(config.extraParams[WebAccess.EXTRA_PARAM_REQUIRED_TOOL]) + assertNull(config.extraParams[EXTRA_PARAM_REQUIRED_TOOL]) } }