From 12776e5f479c188df063fcd2000af302c1fcbd9c Mon Sep 17 00:00:00 2001 From: Carl-Robert Linnupuu Date: Tue, 17 Mar 2026 23:49:37 +0000 Subject: [PATCH] fix: external agent tool call argument handling for WebFetch --- .../codegpt/agent/external/AcpAgentService.kt | 31 +++++++- .../agent/external/AcpSessionUpdateParser.kt | 5 +- .../agent/external/AcpToolCallDecoder.kt | 30 ++++++++ .../service/custom/form/CustomServiceForm.kt | 5 +- .../descriptor/ToolCallDescriptorFactory.kt | 74 +++++++++++++++---- 5 files changed, 126 insertions(+), 19 deletions(-) diff --git a/src/main/kotlin/ee/carlrobert/codegpt/agent/external/AcpAgentService.kt b/src/main/kotlin/ee/carlrobert/codegpt/agent/external/AcpAgentService.kt index 8bcb9221..d84369ad 100644 --- a/src/main/kotlin/ee/carlrobert/codegpt/agent/external/AcpAgentService.kt +++ b/src/main/kotlin/ee/carlrobert/codegpt/agent/external/AcpAgentService.kt @@ -495,7 +495,9 @@ class ExternalAcpAgentService(private val project: Project) { private fun handleToolCall(update: AcpSessionUpdate.ToolCall, events: AgentEvents) { val toolCall = update.toolCall toolCallsById[toolCall.id] = ExternalToolCall(toolCall.toolName, toolCall.args) - events.onToolStarting(toolCall.id, toolCall.toolName, toolCall.args) + if (!shouldDeferToolStart(toolCall.toolName, toolCall.args, update.status)) { + events.onToolStarting(toolCall.id, toolCall.toolName, toolCall.args) + } if (update.status?.isTerminal == true) { completeToolCall( @@ -513,15 +515,28 @@ class ExternalAcpAgentService(private val project: Project) { update: AcpSessionUpdate.ToolCallUpdate, events: AgentEvents ) { + val updatedToolCall = update.toolCall + val currentToolCall = toolCallsById[update.toolCallId] + val effectiveToolName = updatedToolCall?.toolName ?: currentToolCall?.toolName ?: "Tool" + val effectiveArgs = updatedToolCall?.args ?: currentToolCall?.args + + if (updatedToolCall != null) { + toolCallsById[update.toolCallId] = + ExternalToolCall(effectiveToolName, effectiveArgs) + } + + if (currentToolCall?.args == null && effectiveArgs != null) { + events.onToolStarting(update.toolCallId, effectiveToolName, effectiveArgs) + } + if (!update.status.isTerminal) { return } - val toolCall = toolCallsById[update.toolCallId] completeToolCall( toolCallId = update.toolCallId, - toolName = toolCall?.toolName ?: "Tool", - args = toolCall?.args, + toolName = effectiveToolName, + args = effectiveArgs, status = update.status, rawOutput = update.rawOutput, events = events @@ -546,6 +561,14 @@ class ExternalAcpAgentService(private val project: Project) { events.onToolCompleted(toolCallId, toolName, result) } + private fun shouldDeferToolStart( + toolName: String, + args: Any?, + status: AcpToolCallStatus? + ): Boolean { + return toolName == "WebFetch" && args == null && status?.isTerminal != true + } + private fun handleConfigOptionUpdate(update: JsonObject) { project.service() .getSession(proxySessionId) diff --git a/src/main/kotlin/ee/carlrobert/codegpt/agent/external/AcpSessionUpdateParser.kt b/src/main/kotlin/ee/carlrobert/codegpt/agent/external/AcpSessionUpdateParser.kt index f27c3d69..d2059994 100644 --- a/src/main/kotlin/ee/carlrobert/codegpt/agent/external/AcpSessionUpdateParser.kt +++ b/src/main/kotlin/ee/carlrobert/codegpt/agent/external/AcpSessionUpdateParser.kt @@ -14,6 +14,7 @@ internal sealed interface AcpSessionUpdate { data class ToolCallUpdate( val toolCallId: String, + val toolCall: AcpDecodedToolCall?, val status: AcpToolCallStatus, val rawOutput: JsonElement? ) : AcpSessionUpdate @@ -22,12 +23,13 @@ internal sealed interface AcpSessionUpdate { } internal enum class AcpToolCallStatus(val wireValue: String) { + IN_PROGRESS("in_progress"), COMPLETED("completed"), FAILED("failed"), CANCELLED("cancelled"); val isTerminal: Boolean - get() = true + get() = this != IN_PROGRESS companion object { fun fromWireValue(value: String?): AcpToolCallStatus? { @@ -84,6 +86,7 @@ internal class AcpSessionUpdateParser( val status = AcpToolCallStatus.fromWireValue(update.string("status")) ?: return null return AcpSessionUpdate.ToolCallUpdate( toolCallId = toolCallId, + toolCall = toolCallDecoder.decodeToolCall(update), status = status, rawOutput = update["rawOutput"] ?: update["content"] ) diff --git a/src/main/kotlin/ee/carlrobert/codegpt/agent/external/AcpToolCallDecoder.kt b/src/main/kotlin/ee/carlrobert/codegpt/agent/external/AcpToolCallDecoder.kt index da9012e3..46bbc98e 100644 --- a/src/main/kotlin/ee/carlrobert/codegpt/agent/external/AcpToolCallDecoder.kt +++ b/src/main/kotlin/ee/carlrobert/codegpt/agent/external/AcpToolCallDecoder.kt @@ -199,6 +199,7 @@ internal class AcpToolCallDecoder( "Read" -> decodeReadArgs(obj, metadata) ?: payload.ifBlank { null } "IntelliJSearch" -> decodeSearchArgs(obj) ?: payload.ifBlank { null } "Bash" -> decodeBashArgs(obj) ?: payload.ifBlank { null } + "WebFetch" -> decodeWebFetchArgs(obj, rawInput, metadata) ?: payload.ifBlank { null } else -> payload.ifBlank { null } } } @@ -291,6 +292,27 @@ internal class AcpToolCallDecoder( ) } + private fun decodeWebFetchArgs( + obj: JsonObject, + rawInput: JsonElement?, + metadata: JsonObject? = null + ): WebFetchTool.Args? { + val payload = rawInput.toPayloadString() + val url = obj.string("url", "uri", "href", "link") + ?: metadata?.string("url", "uri") + ?: extractFirstUrl(payload) + ?: metadata?.string("title")?.let(::extractFirstUrl) + ?: return null + + return WebFetchTool.Args( + url = url, + selector = obj.string("selector", "css_selector", "cssSelector"), + timeoutMs = obj.int("timeout_ms", "timeoutMs", "timeout") ?: 10_000, + offset = obj.int("offset", "start_line", "startLine"), + limit = obj.int("limit", "max_lines", "maxLines", "count") + ) + } + private fun decodeMcpArgs(rawTitle: String, rawInput: JsonElement?): McpTool.Args { val obj = rawInput.asJsonObjectOrNull(json) ?: JsonObject(emptyMap()) val callName = rawTitle.ifBlank { obj.string("tool_name", "toolName") ?: "unknown" } @@ -333,4 +355,12 @@ internal class AcpToolCallDecoder( val slashIndex = candidate.indexOf('/') return slashIndex > 0 && slashIndex < candidate.length - 1 } + + private fun extractFirstUrl(text: String): String? { + return URL_REGEX.find(text)?.value + } + + private companion object { + val URL_REGEX = Regex("""https?://[^\s"'<>]+""") + } } diff --git a/src/main/kotlin/ee/carlrobert/codegpt/settings/service/custom/form/CustomServiceForm.kt b/src/main/kotlin/ee/carlrobert/codegpt/settings/service/custom/form/CustomServiceForm.kt index 3f80ed1e..4f2607f8 100644 --- a/src/main/kotlin/ee/carlrobert/codegpt/settings/service/custom/form/CustomServiceForm.kt +++ b/src/main/kotlin/ee/carlrobert/codegpt/settings/service/custom/form/CustomServiceForm.kt @@ -645,7 +645,10 @@ class CustomServiceForm( private fun createTokenSpinner(initialValue: Long): JSpinner = JSpinner(SpinnerNumberModel(initialValue, 1L, Long.MAX_VALUE, 1L)).apply { - (editor as? JSpinner.DefaultEditor)?.textField?.columns = 10 + (editor as? JSpinner.DefaultEditor)?.textField?.apply { + columns = 10 + font = nameField.font + } } private fun readSpinnerValue(spinner: JSpinner, fallback: Long): Long { diff --git a/src/main/kotlin/ee/carlrobert/codegpt/toolwindow/agent/ui/descriptor/ToolCallDescriptorFactory.kt b/src/main/kotlin/ee/carlrobert/codegpt/toolwindow/agent/ui/descriptor/ToolCallDescriptorFactory.kt index bc8e6982..a8c5bd7a 100644 --- a/src/main/kotlin/ee/carlrobert/codegpt/toolwindow/agent/ui/descriptor/ToolCallDescriptorFactory.kt +++ b/src/main/kotlin/ee/carlrobert/codegpt/toolwindow/agent/ui/descriptor/ToolCallDescriptorFactory.kt @@ -528,21 +528,12 @@ object ToolCallDescriptorFactory { result: Any?, projectId: String? ): ToolCallDescriptor { - val query = when (args) { - is WebSearchTool.Args -> args.query - is WebFetchTool.Args -> args.url - else -> "Unknown" - } + val isFetch = isWebFetchArgs(args) || result is WebFetchTool.Result + val query = extractWebQuery(args, result) - val titlePrefix = when (args) { - is WebFetchTool.Args -> "Fetch:" - else -> "Web:" - } + val titlePrefix = if (isFetch) "Fetch:" else "Web:" - val tooltip = when (args) { - is WebFetchTool.Args -> "Fetch: $query" - else -> "Web search: $query" - } + val tooltip = if (isFetch) "Fetch: $query" else "Web search: $query" val truncatedQuery = truncateQuery(query) @@ -559,6 +550,45 @@ object ToolCallDescriptorFactory { ) } + private fun extractWebQuery(args: Any, result: Any?): String { + return when (args) { + is WebSearchTool.Args -> args.query + is WebFetchTool.Args -> args.url + is JsonObject -> jsonObjectString( + args, + "url", + "uri", + "href", + "link", + "query", + "q" + ) ?: extractFirstUrl(args.toString()) ?: "Unknown" + + is Map<*, *> -> mapString( + args, + "url", + "uri", + "href", + "link", + "query", + "q" + ) ?: extractFirstUrl(args.toString()) ?: "Unknown" + + is String -> extractFirstUrl(args) ?: args.takeIf { it.isNotBlank() } ?: "Unknown" + else -> (result as? WebFetchTool.Result)?.url ?: "Unknown" + } + } + + private fun isWebFetchArgs(args: Any): Boolean { + return when (args) { + is WebFetchTool.Args -> true + is JsonObject -> jsonObjectString(args, "url", "uri", "href", "link") != null + is Map<*, *> -> mapString(args, "url", "uri", "href", "link") != null + is String -> extractFirstUrl(args) != null + else -> false + } + } + private fun createTaskDescriptor( args: Any, result: Any?, @@ -810,6 +840,24 @@ object ToolCallDescriptorFactory { } } + private fun jsonObjectString(obj: JsonObject, vararg keys: String): String? { + return keys.firstNotNullOfOrNull { key -> + (obj[key] as? JsonPrimitive)?.contentOrNull?.takeIf { it.isNotBlank() } + } + } + + private fun mapString(map: Map<*, *>, vararg keys: String): String? { + return keys.firstNotNullOfOrNull { key -> + map[key]?.toString()?.takeIf { it.isNotBlank() } + } + } + + private fun extractFirstUrl(text: String): String? { + return URL_REGEX.find(text)?.value + } + + private val URL_REGEX = Regex("""https?://[^\s"'<>]+""") + private fun truncateCommand(command: String): String { return if (command.length > AgentUiConfig.BASH_CMD_MAX) { command.take(AgentUiConfig.BASH_CMD_MAX) + "..."