feat(tasks): agent-loop integration — task_ready tool + claim guard

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 <noreply@anthropic.com>
This commit is contained in:
2026-06-24 17:34:24 +00:00
parent e77b2960f9
commit 215a951b52
4 changed files with 76 additions and 1 deletions
@@ -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<ToolCapability> = 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()),
)
}
}
@@ -27,6 +27,7 @@ object TaskTools {
TaskUpdateTool(service),
TaskDeleteTool(service),
TaskSearchTool(service),
TaskReadyTool(service),
TaskContextTool(
TaskContextAssembler(service, retriever, documentResolver, artifactResolver, sessionResolver),
),
@@ -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")
@@ -14,6 +14,10 @@ internal fun ToolRequest.stringParam(name: String): String? =
internal fun ToolRequest.listParam(name: String): List<String> =
(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).