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
@@ -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,
@@ -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)
}