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
@@ -26,4 +26,8 @@ class DefaultTaskClaimCoordinator(
override fun activeScope(sessionId: SessionId): List<String> = override fun activeScope(sessionId: SessionId): List<String> =
taskService.activeClaim(sessionId.value)?.state?.affectedPaths ?: emptyList() taskService.activeClaim(sessionId.value)?.state?.affectedPaths ?: emptyList()
override suspend fun blockActiveTask(sessionId: SessionId, reason: String) {
taskService.activeClaim(sessionId.value)?.let { taskService.block(it.taskId, reason) }
}
} }
@@ -154,6 +154,7 @@ import kotlin.coroutines.cancellation.CancellationException
private const val MAX_TOOL_ROUNDS = 10 private const val MAX_TOOL_ROUNDS = 10
private const val STAGE_COMPLETE_TOOL = "stage_complete" private const val STAGE_COMPLETE_TOOL = "stage_complete"
private const val SCOPE_PROPOSAL_TOOL = "propose_scope"
private const val READ_BEFORE_WRITE_CODE = "READ_BEFORE_WRITE" private const val READ_BEFORE_WRITE_CODE = "READ_BEFORE_WRITE"
private const val OUTPUT_SUMMARY_LIMIT = 500 private const val OUTPUT_SUMMARY_LIMIT = 500
private const val REPO_MAP_INJECT_TOP_K = 30 private const val REPO_MAP_INJECT_TOP_K = 30
@@ -812,6 +813,7 @@ abstract class SessionOrchestrator(
emitDecisionResolved(sessionId, domainRequest, engineDecision) emitDecisionResolved(sessionId, domainRequest, engineDecision)
if (!engineDecision.isApproved) { if (!engineDecision.isApproved) {
val rejectReason = engineDecision.reason ?: "denied" val rejectReason = engineDecision.reason ?: "denied"
blockTaskOnScopeRejection(sessionId, toolCall.function.name, rejectReason)
emit(sessionId, ToolExecutionRejectedEvent( emit(sessionId, ToolExecutionRejectedEvent(
invocationId = invocationId, invocationId = invocationId,
sessionId = sessionId, sessionId = sessionId,
@@ -869,6 +871,7 @@ abstract class SessionOrchestrator(
emitDecisionResolved(sessionId, domainRequest, userDecision) emitDecisionResolved(sessionId, domainRequest, userDecision)
if (!userDecision.isApproved) { if (!userDecision.isApproved) {
val rejectReason = userDecision.reason ?: "approval denied" val rejectReason = userDecision.reason ?: "approval denied"
blockTaskOnScopeRejection(sessionId, toolCall.function.name, rejectReason)
emit(sessionId, ToolExecutionRejectedEvent( emit(sessionId, ToolExecutionRejectedEvent(
invocationId = invocationId, invocationId = invocationId,
sessionId = sessionId, sessionId = sessionId,
@@ -992,6 +995,17 @@ abstract class SessionOrchestrator(
} }
} }
/**
* A rejected [SCOPE_PROPOSAL_TOOL] call means the operator denied widening the claimed task's
* scope — block the task so the loop advances instead of the implementer retrying the same
* out-of-scope write. Only fires for the scope-proposal tool; other denied tools are unaffected.
*/
private suspend fun blockTaskOnScopeRejection(sessionId: SessionId, toolName: String, reason: String) {
if (toolName == SCOPE_PROPOSAL_TOOL) {
taskClaimCoordinator?.blockActiveTask(sessionId, "scope amendment rejected: $reason")
}
}
private suspend fun runPlane2Assessment( private suspend fun runPlane2Assessment(
sessionId: SessionId, sessionId: SessionId,
stageId: StageId, stageId: StageId,
@@ -19,4 +19,12 @@ interface TaskClaimCoordinator {
/** Write-scope (affected paths) of the task the session currently has claimed; empty when none. */ /** Write-scope (affected paths) of the task the session currently has claimed; empty when none. */
fun activeScope(sessionId: SessionId): List<String> fun activeScope(sessionId: SessionId): List<String>
/**
* Block the session's currently-claimed task — used when the operator rejects a scope-amendment
* proposal: the task can't proceed within scope, so it leaves the active/ready set and the loop
* advances to the next task instead of the implementer retrying the blocked write. No-op when
* nothing is claimed.
*/
suspend fun blockActiveTask(sessionId: SessionId, reason: String)
} }
@@ -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), TaskCreateTool(service),
TaskDecomposeTool(service), TaskDecomposeTool(service),
TaskUpdateTool(service, sessionFacts, sessionWrites), TaskUpdateTool(service, sessionFacts, sessionWrites),
ProposeScopeTool(service),
TaskDeleteTool(service), TaskDeleteTool(service),
TaskSearchTool(service), TaskSearchTool(service),
TaskReadyTool(service), TaskReadyTool(service),
@@ -402,6 +402,29 @@ class TaskToolsTest {
// Deleting again now fails: it is gone from active views. // Deleting again now fails: it is gone from active views.
assertTrue(delete.execute(request("task_delete", mapOf("id" to id))) is ToolResult.Failure) 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. */ /** Minimal append-capable in-memory store for tool tests. */