fix: external agent tool call argument handling for WebFetch

This commit is contained in:
Carl-Robert Linnupuu 2026-03-17 23:49:37 +00:00
parent 6a53318fea
commit 12776e5f47
5 changed files with 126 additions and 19 deletions

View file

@ -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<AgentToolWindowContentManager>()
.getSession(proxySessionId)

View file

@ -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"]
)

View file

@ -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"'<>]+""")
}
}

View file

@ -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 {

View file

@ -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) + "..."