feat(research): WebSearchTool — local SearXNG search (research-workflow §3)

Searches the self-hosted SearXNG JSON API: local-first, no API keys, no third-party
telemetry. Hits only the configured endpoint, so unlike web_fetch there is no
arbitrary-host egress to police — T1. Returns the top-N results as a readable list
(title / url / snippet) with the result URLs in metadata so the fetch stage can act on
them and the operator can approve the source list rather than each URL.

Tested with Ktor MockEngine (query encoding, result parsing/cap, empty + error paths).
Completes the §3 "two tools" pair; with slice-1 extraction this is the deterministic
+ network-tool foundation of the research workflow.
This commit is contained in:
2026-06-13 22:56:32 +04:00
parent 8d815d63da
commit 93d325e230
2 changed files with 219 additions and 0 deletions
@@ -0,0 +1,115 @@
package com.correx.infrastructure.research.web
import com.correx.core.approvals.Tier
import com.correx.core.events.events.ToolRequest
import com.correx.core.events.types.ToolInvocationId
import com.correx.core.tools.contract.Tool
import com.correx.core.tools.contract.ToolCapability
import com.correx.core.tools.contract.ToolExecutor
import com.correx.core.tools.contract.ToolResult
import com.correx.core.tools.contract.ValidationResult
import io.ktor.client.HttpClient
import io.ktor.client.request.get
import io.ktor.client.statement.bodyAsText
import io.ktor.http.isSuccess
import kotlinx.serialization.Serializable
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.JsonPrimitive
import kotlinx.serialization.json.buildJsonArray
import kotlinx.serialization.json.buildJsonObject
import kotlinx.serialization.json.put
import kotlinx.serialization.json.putJsonObject
import java.net.URLEncoder
/**
* Searches the self-hosted SearXNG instance (research-workflow-spec §3). Local-first: no API keys,
* no third-party telemetry. Search hits only the one configured [searxngBaseUrl], so unlike
* [WebFetchTool] there is no arbitrary-host egress to police — it is [Tier.T1].
*
* Returns the top [maxResults] results as a readable list, with the result URLs in metadata so the
* fetch stage can act on them (and the operator can approve the source list, not each URL).
*/
class WebSearchTool(
private val httpClient: HttpClient,
private val searxngBaseUrl: String,
private val maxResults: Int = DEFAULT_MAX_RESULTS,
) : Tool, ToolExecutor {
private val json = Json { ignoreUnknownKeys = true }
override val name: String = "web_search"
override val description: String = "Search the web (local SearXNG); returns titles, URLs, and snippets."
override val tier: Tier = Tier.T1
override val requiredCapabilities: Set<ToolCapability> = setOf(ToolCapability.NETWORK_ACCESS)
override val parametersSchema: JsonObject = buildJsonObject {
put("type", "object")
putJsonObject("properties") {
putJsonObject("query") {
put("type", "string")
put("description", "Search query.")
}
}
put("required", buildJsonArray { add(JsonPrimitive("query")) })
}
override fun validateRequest(request: ToolRequest): ValidationResult {
val query = request.parameters["query"] as? String
return if (query.isNullOrBlank()) {
ValidationResult.Invalid("Missing 'query' parameter.")
} else {
ValidationResult.Valid
}
}
override suspend fun execute(request: ToolRequest): ToolResult {
val invalid = validateRequest(request) as? ValidationResult.Invalid
if (invalid != null) return ToolResult.Failure(request.invocationId, invalid.reason, recoverable = false)
val query = request.parameters["query"] as String
return runCatching { search(request.invocationId, query) }
.getOrElse { ToolResult.Failure(request.invocationId, "search failed: ${it.message}", recoverable = true) }
}
private suspend fun search(invocationId: ToolInvocationId, query: String): ToolResult {
val url = "${searxngBaseUrl.trimEnd('/')}/search?q=${URLEncoder.encode(query, "UTF-8")}&format=json"
val response = httpClient.get(url)
if (!response.status.isSuccess()) {
return ToolResult.Failure(invocationId, "SearXNG HTTP ${response.status.value}", recoverable = true)
}
val results = json.decodeFromString<SearxResponse>(response.bodyAsText()).results.take(maxResults)
return ToolResult.Success(
invocationId = invocationId,
output = render(query, results),
metadata = mapOf(
"query" to query,
"result_count" to results.size.toString(),
"urls" to results.joinToString("\n") { it.url },
),
)
}
private fun render(query: String, results: List<SearxResult>): String {
if (results.isEmpty()) return "No results for: $query"
return results.mapIndexed { index, r ->
buildString {
append("${index + 1}. ${r.title.ifBlank { r.url }}\n")
append(" ${r.url}")
if (r.content.isNotBlank()) append("\n ${r.content.trim()}")
}
}.joinToString("\n\n")
}
@Serializable
private data class SearxResponse(val results: List<SearxResult> = emptyList())
@Serializable
private data class SearxResult(
val url: String = "",
val title: String = "",
val content: String = "",
)
companion object {
const val DEFAULT_MAX_RESULTS = 8
}
}
@@ -0,0 +1,104 @@
package com.correx.infrastructure.research.web
import com.correx.core.approvals.Tier
import com.correx.core.events.events.ToolRequest
import com.correx.core.tools.contract.ToolCapability
import com.correx.core.tools.contract.ToolResult
import com.correx.core.tools.contract.ValidationResult
import com.correx.core.utils.TypeId
import io.ktor.client.HttpClient
import io.ktor.client.engine.mock.MockEngine
import io.ktor.client.engine.mock.respond
import io.ktor.http.HttpStatusCode
import io.ktor.http.headersOf
import io.ktor.http.HttpHeaders
import kotlinx.coroutines.runBlocking
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertInstanceOf
import org.junit.jupiter.api.Assertions.assertTrue
import org.junit.jupiter.api.Test
class WebSearchToolTest {
private var lastRequestedUrl: String = ""
private fun toolFor(
json: String,
status: HttpStatusCode = HttpStatusCode.OK,
maxResults: Int = 8,
): WebSearchTool {
val engine = MockEngine { request ->
lastRequestedUrl = request.url.toString()
respond(content = json, status = status, headers = headersOf(HttpHeaders.ContentType, "application/json"))
}
return WebSearchTool(HttpClient(engine), searxngBaseUrl = "http://searx.local:8888", maxResults = maxResults)
}
private fun request(query: String?) = ToolRequest(
invocationId = TypeId("inv-1"),
sessionId = TypeId("s-1"),
stageId = TypeId("stage-1"),
toolName = "web_search",
parameters = if (query == null) emptyMap() else mapOf("query" to query),
)
private fun search(tool: WebSearchTool, query: String = "kotlin coroutines") =
runBlocking { tool.execute(request(query)) }
private val sample = """
{"results":[
{"url":"https://a.com/1","title":"First","content":"about one"},
{"url":"https://b.com/2","title":"Second","content":"about two"}
]}
""".trimIndent()
@Test
fun `is tier one and network capable`() {
val tool = toolFor(sample)
assertEquals(Tier.T1, tool.tier)
assertTrue(ToolCapability.NETWORK_ACCESS in tool.requiredCapabilities)
}
@Test
fun `queries searxng json endpoint with encoded query`() {
search(toolFor(sample), "a b&c")
assertTrue(lastRequestedUrl.contains("/search?q="), lastRequestedUrl)
assertTrue(lastRequestedUrl.contains("format=json"), lastRequestedUrl)
assertTrue(lastRequestedUrl.contains("a+b%26c"), "query not url-encoded: $lastRequestedUrl")
}
@Test
fun `renders results and exposes urls in metadata`() {
val success = assertInstanceOf(ToolResult.Success::class.java, search(toolFor(sample)))
assertTrue(success.output.contains("First"))
assertTrue(success.output.contains("https://a.com/1"))
assertEquals("2", success.metadata["result_count"])
assertEquals("https://a.com/1\nhttps://b.com/2", success.metadata["urls"])
}
@Test
fun `respects max results cap`() {
val success = assertInstanceOf(ToolResult.Success::class.java, search(toolFor(sample, maxResults = 1)))
assertEquals("1", success.metadata["result_count"])
assertEquals("https://a.com/1", success.metadata["urls"])
}
@Test
fun `empty results render a no-results message`() {
val success = assertInstanceOf(ToolResult.Success::class.java, search(toolFor("""{"results":[]}""")))
assertTrue(success.output.contains("No results"))
assertEquals("0", success.metadata["result_count"])
}
@Test
fun `searxng error is a recoverable failure`() {
val result = search(toolFor("err", status = HttpStatusCode.BadGateway))
val failure = assertInstanceOf(ToolResult.Failure::class.java, result)
assertTrue(failure.recoverable)
}
@Test
fun `blank query is rejected`() {
assertInstanceOf(ValidationResult.Invalid::class.java, toolFor(sample).validateRequest(request(" ")))
}
}