feat(tools): propose_scope recalibration valve for the execution loop

When a write is blocked for being outside the claimed task's
affected_paths, the implementer can propose widening scope via a new
T2 propose_scope tool. The operator decides (invariant #4 — the agent
can't self-grant). Approve unions the proposed paths into the task's
affected_paths (existing TaskAffectedPathsSetEvent); activeScope
re-derives the wider manifest from events so the same write then passes.
Reject blocks the task (via TaskClaimCoordinator.blockActiveTask, hooked
on both approval-denial branches, scoped to propose_scope) so the loop
advances. No new event types.
This commit is contained in:
2026-06-29 01:04:48 +04:00
parent 3e44c6d107
commit a00bd4aade
6 changed files with 136 additions and 0 deletions
@@ -0,0 +1,86 @@
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.TaskNoteAuthor
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
/**
* Agent-facing tool: when a write is blocked because the path is outside the claimed task's
* affected_paths, propose widening the scope. This is the recalibration valve for the execution
* loop — the implementer cannot grant itself more room (invariant #4): the tool is [Tier.T2], so it
* only executes after the operator approves. On approval it unions the proposed paths into the
* task's affected_paths ([TaskService.setAffectedPaths] → event); [TaskClaimCoordinator.activeScope]
* re-derives the wider manifest from events on the next tool call, so the same write then passes.
* On rejection the kernel blocks the task and the loop advances (see SessionOrchestrator).
*
* Operates on the session's currently-claimed task, so only the paths to add are needed.
*/
class ProposeScopeTool(private val service: TaskService) : Tool, ToolExecutor {
override val name: String = "propose_scope"
override val description: String =
"Propose widening the current task's affected_paths when a write is blocked for being out " +
"of scope. The operator must approve. On approval the paths are added and the blocked " +
"write can proceed; on rejection the task is blocked. Give the paths to add and why."
override val parametersSchema: JsonObject = buildJsonObject {
put("type", "object")
putJsonObject("properties") {
putJsonObject("paths") {
put("type", "array")
putJsonObject("items") { put("type", "string") }
put("description", "Path globs to add to the task's affected_paths.")
}
putJsonObject("reason") {
put("type", "string")
put("description", "Why the wider scope is needed — recorded on the task.")
}
}
put("required", buildJsonArray { add(JsonPrimitive("paths")) })
}
override val tier: Tier = Tier.T2
override val requiredCapabilities: Set<ToolCapability> = emptySet()
override fun validateRequest(request: ToolRequest): ValidationResult =
if (request.listParam("paths").isEmpty()) {
ValidationResult.Invalid("Missing 'paths' (non-empty string array).")
} else {
ValidationResult.Valid
}
override suspend fun execute(request: ToolRequest): ToolResult {
val task = service.activeClaim(request.sessionId.value)
?: return fail(request, "No claimed task for this session — nothing to widen scope for.")
val add = request.listParam("paths")
if (add.isEmpty()) return fail(request, "Missing 'paths' (non-empty string array).")
val widened = (task.state.affectedPaths + add).distinct()
service.setAffectedPaths(task.taskId, widened)
val reason = request.stringParam("reason")
service.addNote(
task.taskId,
TaskNoteAuthor.AGENT,
"[scope] widened +$add${reason?.let { ": $it" }.orEmpty()}",
)
return ToolResult.Success(
invocationId = request.invocationId,
output = "Scope of ${task.taskId.value} widened to $widened — retry the blocked write.",
metadata = mapOf("taskId" to task.taskId.value),
)
}
private fun fail(request: ToolRequest, reason: String): ToolResult.Failure =
ToolResult.Failure(request.invocationId, reason, recoverable = false)
}
@@ -28,6 +28,7 @@ object TaskTools {
TaskCreateTool(service),
TaskDecomposeTool(service),
TaskUpdateTool(service, sessionFacts, sessionWrites),
ProposeScopeTool(service),
TaskDeleteTool(service),
TaskSearchTool(service),
TaskReadyTool(service),
@@ -402,6 +402,29 @@ class TaskToolsTest {
// Deleting again now fails: it is gone from active views.
assertTrue(delete.execute(request("task_delete", mapOf("id" to id))) is ToolResult.Failure)
}
@Test
fun `propose_scope unions new paths into the claimed task and keeps existing`() = runBlocking {
val id = createTask()
update.execute(request("task_update", mapOf("id" to id, "affected_paths" to listOf("src/a.kt"))))
update.execute(request("task_update", mapOf("id" to id, "action" to "claim")))
val result = ProposeScopeTool(service).execute(
request("propose_scope", mapOf("paths" to listOf("src/b.kt"), "reason" to "shared helper")),
)
assertTrue(result is ToolResult.Success)
assertEquals(listOf("src/a.kt", "src/b.kt"), service.getTask(TaskId(id))?.state?.affectedPaths)
}
@Test
fun `propose_scope fails when the session has no claimed task`() = runBlocking {
createTask() // exists but unclaimed
val result = ProposeScopeTool(service).execute(
request("propose_scope", mapOf("paths" to listOf("src/b.kt"))),
)
assertTrue(result is ToolResult.Failure)
}
}
/** Minimal append-capable in-memory store for tool tests. */