Building StareBrain, Part 22: Plan Validation — Checking Safety Before Execution Begins
Part 21 covered the planning layer: how a raw user instruction becomes an AgentTask with concrete AgentSteps. Today we add a step that happens between planning and execution — plan validation.
The problem: TaskPlanner can generate a step that would trigger ConfirmationGuard at runtime. That's late. We want to catch high-risk steps before the agent starts moving, not mid-task when the user is watching something execute.
Why Validate Before Execution?
If ConfirmationGuard fires mid-task, the user has to pause, evaluate a confirmation prompt, and decide whether to continue — in the middle of a sequence of actions already underway. That's a worse experience than surfacing the confirmation before anything starts.
Plan validation moves the confirmation upstream: show the user what's about to happen, including the risky steps, before the first action runs.
PlanValidator
PlanValidator inspects each step in a generated plan and classifies it:
class PlanValidator {
sealed class StepRisk {
object Safe : StepRisk()
data class RequiresConfirmation(val reason: String) : StepRisk()
data class Blocked(val reason: String) : StepRisk()
}
data class ValidationResult(
val stepRisks: List<Pair<AgentStep, StepRisk>>,
val overallSafe: Boolean
) {
val requiresConfirmation: Boolean
get() = stepRisks.any { it.second is StepRisk.RequiresConfirmation }
val hasBlockedSteps: Boolean
get() = stepRisks.any { it.second is StepRisk.Blocked }
}
fun validate(task: AgentTask): ValidationResult {
val stepRisks = task.steps.map { step ->
step to classifyStep(step)
}
val overallSafe = stepRisks.none { it.second is StepRisk.Blocked }
return ValidationResult(stepRisks, overallSafe)
}
private fun classifyStep(step: AgentStep): StepRisk {
val instruction = step.instruction.lowercase()
// Blocked: steps that should never execute without explicit user initiation
val blockedKeywords = listOf("factory reset", "wipe", "format device")
blockedKeywords.forEach { keyword ->
if (instruction.contains(keyword)) {
return StepRisk.Blocked("Step contains blocked action: $keyword")
}
}
// Confirmation required: matches ConfirmationGuard keyword set
if (step.requiresConfirmation || ConfirmationGuard.requiresConfirmation(step.instruction)) {
return StepRisk.RequiresConfirmation(
ConfirmationGuard.describeRisk(step.instruction)
)
}
return StepRisk.Safe
}
}
Connecting to ConfirmationGuard
PlanValidator reuses ConfirmationGuard's detection logic so the two systems stay in sync:
object ConfirmationGuard {
fun requiresConfirmation(instruction: String): Boolean {
val lower = instruction.lowercase()
return HIGH_RISK_PATTERNS.any { pattern ->
lower.containsWholeWord(pattern)
}
}
fun describeRisk(instruction: String): String {
val lower = instruction.lowercase()
return when {
lower.containsWholeWord("send") || lower.containsWholeWord("message") ->
"This step sends a message"
lower.containsWholeWord("pay") || lower.containsWholeWord("purchase") ||
lower.containsWholeWord("order") ->
"This step involves a payment"
lower.containsWholeWord("delete") || lower.containsWholeWord("remove") ->
"This step deletes data"
else -> "This step requires confirmation"
}
}
}
Upfront Confirmation UI
When validation finds steps requiring confirmation, the overlay shows a plan summary before execution:
sealed class OverlayState {
object Idle : OverlayState()
object Planning : OverlayState()
data class AwaitingPlanApproval( // new
val task: AgentTask,
val riskySteps: List<Pair<AgentStep, String>>, // step + reason
val onApprove: () -> Unit,
val onReject: () -> Unit
) : OverlayState()
data class Running(...) : OverlayState()
data class AwaitingConfirmation(...) : OverlayState()
}
The user sees: "StareBrain will do X steps. Step 3 involves a payment. Proceed?" — before anything runs.
In the Pipeline
private fun launchPipeline(instruction: String, context: AgentContext) {
lifecycleScope.launch {
overlayManager.setState(OverlayState.Planning)
when (val planResult = taskPlanner.plan(instruction, context)) {
is PlanResult.Failure -> {
overlayManager.showError(planResult.reason)
return@launch
}
is PlanResult.Success -> {
val task = planResult.task
val validation = planValidator.validate(task)
when {
validation.hasBlockedSteps -> {
val blocked = validation.stepRisks
.filter { it.second is StepRisk.Blocked }
.map { (it.second as StepRisk.Blocked).reason }
overlayManager.showError("Can't run this plan: ${blocked.first()}")
}
validation.requiresConfirmation -> {
val riskySteps = validation.stepRisks
.filter { it.second is StepRisk.RequiresConfirmation }
.map { it.first to (it.second as StepRisk.RequiresConfirmation).reason }
overlayManager.setState(
OverlayState.AwaitingPlanApproval(
task = task,
riskySteps = riskySteps,
onApprove = {
taskPersistence.save(task)
lifecycleScope.launch { taskExecutor.execute(task) }
},
onReject = {
overlayManager.setState(OverlayState.Idle)
}
)
)
}
else -> {
taskPersistence.save(task)
taskExecutor.execute(task)
}
}
}
}
}
}
What's Next
Part 23 will cover the on-device model setup: how StareBrain runs inference locally on Android, the tradeoffs between model size and latency, and how we keep the planning layer fast enough to feel instant.
Top comments (0)