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> =
|
||||
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 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 OUTPUT_SUMMARY_LIMIT = 500
|
||||
private const val REPO_MAP_INJECT_TOP_K = 30
|
||||
@@ -812,6 +813,7 @@ abstract class SessionOrchestrator(
|
||||
emitDecisionResolved(sessionId, domainRequest, engineDecision)
|
||||
if (!engineDecision.isApproved) {
|
||||
val rejectReason = engineDecision.reason ?: "denied"
|
||||
blockTaskOnScopeRejection(sessionId, toolCall.function.name, rejectReason)
|
||||
emit(sessionId, ToolExecutionRejectedEvent(
|
||||
invocationId = invocationId,
|
||||
sessionId = sessionId,
|
||||
@@ -869,6 +871,7 @@ abstract class SessionOrchestrator(
|
||||
emitDecisionResolved(sessionId, domainRequest, userDecision)
|
||||
if (!userDecision.isApproved) {
|
||||
val rejectReason = userDecision.reason ?: "approval denied"
|
||||
blockTaskOnScopeRejection(sessionId, toolCall.function.name, rejectReason)
|
||||
emit(sessionId, ToolExecutionRejectedEvent(
|
||||
invocationId = invocationId,
|
||||
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(
|
||||
sessionId: SessionId,
|
||||
stageId: StageId,
|
||||
|
||||
+8
@@ -19,4 +19,12 @@ interface TaskClaimCoordinator {
|
||||
|
||||
/** Write-scope (affected paths) of the task the session currently has claimed; empty when none. */
|
||||
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),
|
||||
TaskDecomposeTool(service),
|
||||
TaskUpdateTool(service, sessionFacts, sessionWrites),
|
||||
ProposeScopeTool(service),
|
||||
TaskDeleteTool(service),
|
||||
TaskSearchTool(service),
|
||||
TaskReadyTool(service),
|
||||
|
||||
+23
@@ -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. */
|
||||
|
||||
Reference in New Issue
Block a user