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:
+61
@@ -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),
|
||||
),
|
||||
|
||||
+10
-1
@@ -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")
|
||||
|
||||
+4
@@ -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).
|
||||
|
||||
Reference in New Issue
Block a user