feat(tasks): task search across REST, tool, and TUI
Ranked exact/substring search: TaskSearch (all terms AND, ranked title > key > goal > criteria > notes) over one project or the whole board via TaskService.search. Surfaced as GET /tasks?q=, a read-only task_search tool for agents, and a `/` filter in the TUI board. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
+70
@@ -0,0 +1,70 @@
|
||||
package com.correx.infrastructure.tools.task
|
||||
|
||||
import com.correx.core.approvals.Tier
|
||||
import com.correx.core.events.events.ToolRequest
|
||||
import com.correx.core.events.types.ProjectId
|
||||
import com.correx.core.tasks.TaskService
|
||||
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 kotlinx.serialization.json.JsonObject
|
||||
import kotlinx.serialization.json.JsonPrimitive
|
||||
import kotlinx.serialization.json.add
|
||||
import kotlinx.serialization.json.buildJsonArray
|
||||
import kotlinx.serialization.json.buildJsonObject
|
||||
import kotlinx.serialization.json.put
|
||||
import kotlinx.serialization.json.putJsonObject
|
||||
|
||||
private const val DEFAULT_LIMIT = 20
|
||||
|
||||
/**
|
||||
* Agent-facing tool: find tasks by text across projects (matches id/title/goal/criteria/notes),
|
||||
* ranked by relevance. Read-only, so it sits at the lowest tier (like [TaskContextTool]) — agents
|
||||
* use it to discover task ids before fetching a context bundle.
|
||||
*/
|
||||
class TaskSearchTool(private val service: TaskService) : Tool, ToolExecutor {
|
||||
|
||||
override val name: String = "task_search"
|
||||
override val description: String =
|
||||
"Search tasks by text (matches id/title/goal/acceptance criteria/notes), ranked by " +
|
||||
"relevance. Returns 'id [STATUS] title' lines. Use to find a task id, then task_context."
|
||||
override val parametersSchema: JsonObject = buildJsonObject {
|
||||
put("type", "object")
|
||||
putJsonObject("properties") {
|
||||
putJsonObject("query") { put("type", "string"); put("description", "Free-text query; all terms must match.") }
|
||||
putJsonObject("project") {
|
||||
put("type", "string")
|
||||
put("description", "Limit to a project (e.g. 'auth'). Omit to search every project.")
|
||||
}
|
||||
putJsonObject("limit") { put("type", "integer"); put("description", "Max results (default $DEFAULT_LIMIT).") }
|
||||
}
|
||||
put("required", buildJsonArray { add(JsonPrimitive("query")) })
|
||||
}
|
||||
override val tier: Tier = Tier.T1
|
||||
override val requiredCapabilities: Set<ToolCapability> = emptySet()
|
||||
|
||||
override fun validateRequest(request: ToolRequest): ValidationResult {
|
||||
request.stringParam("query") ?: return ValidationResult.Invalid("Missing 'query' (string).")
|
||||
return ValidationResult.Valid
|
||||
}
|
||||
|
||||
override suspend fun execute(request: ToolRequest): ToolResult {
|
||||
val query = request.stringParam("query")
|
||||
?: return ToolResult.Failure(request.invocationId, "Missing 'query' (string).", recoverable = false)
|
||||
val project = request.stringParam("project")?.let { ProjectId(it) }
|
||||
val limit = (request.parameters["limit"] as? Number)?.toInt()?.takeIf { it > 0 } ?: DEFAULT_LIMIT
|
||||
val hits = service.search(query, project).take(limit)
|
||||
val output = if (hits.isEmpty()) {
|
||||
"no tasks match \"$query\""
|
||||
} else {
|
||||
hits.joinToString("\n") { "${it.taskId.value} [${it.state.status.name}] ${it.state.title.orEmpty()}".trimEnd() }
|
||||
}
|
||||
return ToolResult.Success(
|
||||
invocationId = request.invocationId,
|
||||
output = output,
|
||||
metadata = mapOf("query" to query, "count" to hits.size.toString()),
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -26,6 +26,7 @@ object TaskTools {
|
||||
TaskCreateTool(service),
|
||||
TaskUpdateTool(service),
|
||||
TaskDeleteTool(service),
|
||||
TaskSearchTool(service),
|
||||
TaskContextTool(
|
||||
TaskContextAssembler(service, retriever, documentResolver, artifactResolver, sessionResolver),
|
||||
),
|
||||
|
||||
+17
@@ -18,6 +18,7 @@ import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.emptyFlow
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import org.junit.jupiter.api.Assertions.assertEquals
|
||||
import org.junit.jupiter.api.Assertions.assertFalse
|
||||
import org.junit.jupiter.api.Assertions.assertNull
|
||||
import org.junit.jupiter.api.Assertions.assertTrue
|
||||
import org.junit.jupiter.api.Test
|
||||
@@ -28,6 +29,7 @@ class TaskToolsTest {
|
||||
private val create = TaskCreateTool(service)
|
||||
private val update = TaskUpdateTool(service)
|
||||
private val delete = TaskDeleteTool(service)
|
||||
private val search = TaskSearchTool(service)
|
||||
private val context = TaskContextTool(TaskContextAssembler(service))
|
||||
|
||||
private var counter = 0
|
||||
@@ -138,6 +140,21 @@ class TaskToolsTest {
|
||||
assertTrue(context.execute(request("task_context", mapOf("id" to "auth-999"))) is ToolResult.Failure)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `task_search ranks matches across projects and reports misses`() = runBlocking {
|
||||
create.execute(request("task_create", mapOf("project" to "auth", "title" to "JWT refresh", "goal" to "stay authed")))
|
||||
create.execute(request("task_create", mapOf("project" to "auth", "title" to "Rotate keys", "goal" to "rotate signing keys")))
|
||||
|
||||
val hit = search.execute(request("task_search", mapOf("query" to "jwt")))
|
||||
assertTrue(hit is ToolResult.Success)
|
||||
val out = (hit as ToolResult.Success).output
|
||||
assertTrue(out.contains("auth-1"))
|
||||
assertFalse(out.contains("auth-2"))
|
||||
|
||||
val miss = search.execute(request("task_search", mapOf("query" to "zebra")))
|
||||
assertTrue((miss as ToolResult.Success).output.contains("no tasks match"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `task_delete tombstones the task`() = runBlocking {
|
||||
val id = createTask()
|
||||
|
||||
Reference in New Issue
Block a user