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:
+4
@@ -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) }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+14
@@ -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,
|
||||||
|
|||||||
+8
@@ -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)
|
||||||
}
|
}
|
||||||
|
|||||||
+86
@@ -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),
|
||||||
|
|||||||
+23
@@ -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. */
|
||||||
|
|||||||
Reference in New Issue
Block a user