From 215a951b52f4f4e7600278b1b3cd6f2c879e1f2f Mon Sep 17 00:00:00 2001 From: kami Date: Wed, 24 Jun 2026 17:34:24 +0000 Subject: [PATCH] =?UTF-8?q?feat(tasks):=20agent-loop=20integration=20?= =?UTF-8?q?=E2=80=94=20task=5Fready=20tool=20+=20claim=20guard?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bring the work graph into the agent loop: - task_ready (T1): lists tasks ready to work now (TODO, all dependencies satisfied) so an agent looking for work can pick one and claim it. Surfaces work; never assigns (honouring the rejected /tasks/next). Registered in TaskTools — left unwired in the request-driven example workflows, where it would be noise; it belongs in an autonomous work-pull workflow. - claim guard: task_update action=claim appends a WARNING naming any unmet blockers, so an agent knows when it has jumped a dependency. Surfaced, not hard-blocked — status is derived, so we don't fight the reducer. Co-Authored-By: Claude Opus 4.8 --- .../tools/task/TaskReadyTool.kt | 61 +++++++++++++++++++ .../infrastructure/tools/task/TaskTools.kt | 1 + .../tools/task/TaskUpdateTool.kt | 11 +++- .../infrastructure/tools/task/ToolParams.kt | 4 ++ 4 files changed, 76 insertions(+), 1 deletion(-) create mode 100644 infrastructure/tools/src/main/kotlin/com/correx/infrastructure/tools/task/TaskReadyTool.kt diff --git a/infrastructure/tools/src/main/kotlin/com/correx/infrastructure/tools/task/TaskReadyTool.kt b/infrastructure/tools/src/main/kotlin/com/correx/infrastructure/tools/task/TaskReadyTool.kt new file mode 100644 index 00000000..66e61887 --- /dev/null +++ b/infrastructure/tools/src/main/kotlin/com/correx/infrastructure/tools/task/TaskReadyTool.kt @@ -0,0 +1,61 @@ +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.buildJsonObject +import kotlinx.serialization.json.put +import kotlinx.serialization.json.putJsonObject + +private const val DEFAULT_LIMIT = 20 + +/** + * Agent-facing tool: list tasks that are ready to be worked right now — TODO with every dependency + * satisfied. Read-only, lowest tier. This is how an agent looking for work discovers what it can + * pick up; it then claims one with `task_update action=claim`. It surfaces work, it does not assign. + */ +class TaskReadyTool(private val service: TaskService) : Tool, ToolExecutor { + + override val name: String = "task_ready" + override val description: String = + "List tasks ready to work now — TODO with all dependencies satisfied — when you're looking " + + "for what to pick up. Claim one with task_update action=claim. Optionally scope to a " + + "project. Returns 'id [STATUS] title' lines." + override val parametersSchema: JsonObject = buildJsonObject { + put("type", "object") + putJsonObject("properties") { + putJsonObject("project") { + put("type", "string") + put("description", "Limit to a project (e.g. 'auth'). Omit for the whole board.") + } + putJsonObject("limit") { put("type", "integer"); put("description", "Max results (default $DEFAULT_LIMIT).") } + } + } + override val tier: Tier = Tier.T1 + override val requiredCapabilities: Set = emptySet() + + override fun validateRequest(request: ToolRequest): ValidationResult = ValidationResult.Valid + + override suspend fun execute(request: ToolRequest): ToolResult { + val project = request.stringParam("project")?.let { ProjectId(it) } + val limit = (request.parameters["limit"] as? Number)?.toInt()?.takeIf { it > 0 } ?: DEFAULT_LIMIT + val ready = service.ready(project).take(limit) + val output = if (ready.isEmpty()) { + "no ready tasks" + } else { + ready.joinToString("\n") { "${it.taskId.value} [${it.state.status.name}] ${it.state.title.orEmpty()}".trimEnd() } + } + return ToolResult.Success( + invocationId = request.invocationId, + output = output, + metadata = mapOf("count" to ready.size.toString()), + ) + } +} diff --git a/infrastructure/tools/src/main/kotlin/com/correx/infrastructure/tools/task/TaskTools.kt b/infrastructure/tools/src/main/kotlin/com/correx/infrastructure/tools/task/TaskTools.kt index 3c5e61c6..ee93d5e7 100644 --- a/infrastructure/tools/src/main/kotlin/com/correx/infrastructure/tools/task/TaskTools.kt +++ b/infrastructure/tools/src/main/kotlin/com/correx/infrastructure/tools/task/TaskTools.kt @@ -27,6 +27,7 @@ object TaskTools { TaskUpdateTool(service), TaskDeleteTool(service), TaskSearchTool(service), + TaskReadyTool(service), TaskContextTool( TaskContextAssembler(service, retriever, documentResolver, artifactResolver, sessionResolver), ), diff --git a/infrastructure/tools/src/main/kotlin/com/correx/infrastructure/tools/task/TaskUpdateTool.kt b/infrastructure/tools/src/main/kotlin/com/correx/infrastructure/tools/task/TaskUpdateTool.kt index bf37d5d7..147184b6 100644 --- a/infrastructure/tools/src/main/kotlin/com/correx/infrastructure/tools/task/TaskUpdateTool.kt +++ b/infrastructure/tools/src/main/kotlin/com/correx/infrastructure/tools/task/TaskUpdateTool.kt @@ -105,13 +105,22 @@ class TaskUpdateTool(private val service: TaskService) : Tool, ToolExecutor { request.stringParam("note")?.let { service.addNote(taskId, TaskNoteAuthor.AGENT, it) } val status = service.getTask(taskId)?.state?.status?.name ?: "UNKNOWN" + val warning = if (action == "claim") claimWarning(taskId) else "" return ToolResult.Success( invocationId = request.invocationId, - output = "Updated $id [$status]", + output = "Updated $id [$status]$warning", metadata = mapOf("taskId" to id, "status" to status), ) } + /** When a claimed task still has unmet blockers, flag it so the agent knows it jumped a dependency. */ + private fun claimWarning(taskId: TaskId): String { + val blockers = service.blockers(taskId) + if (blockers.isEmpty()) return "" + val listed = blockers.joinToString(", ") { "${it.taskId.value} [${it.state.status.name}]" } + return " — WARNING: claimed despite unmet blockers: $listed" + } + private suspend fun applyFieldEdits(request: ToolRequest, taskId: TaskId) { val title = request.stringParam("title") val goal = request.stringParam("goal") diff --git a/infrastructure/tools/src/main/kotlin/com/correx/infrastructure/tools/task/ToolParams.kt b/infrastructure/tools/src/main/kotlin/com/correx/infrastructure/tools/task/ToolParams.kt index 5f816711..b422223c 100644 --- a/infrastructure/tools/src/main/kotlin/com/correx/infrastructure/tools/task/ToolParams.kt +++ b/infrastructure/tools/src/main/kotlin/com/correx/infrastructure/tools/task/ToolParams.kt @@ -14,6 +14,10 @@ internal fun ToolRequest.stringParam(name: String): String? = internal fun ToolRequest.listParam(name: String): List = (parameters[name] as? List<*>)?.mapNotNull { it?.toString()?.takeIf(String::isNotBlank) } ?: emptyList() +/** A boolean parameter, accepting a real Boolean or the string "true" (case-insensitive). */ +internal fun ToolRequest.boolParam(name: String): Boolean = + parameters[name] == true || stringParam(name)?.toBoolean() == true + /** * Records the agent's session on the task as a CONTEXT/SESSION link, so the context bundle can * surface the run(s) that created or worked on it (the session resolver inlines status/intent).