DEV Community

StareBrain
StareBrain

Posted on

Building StareBrain, Part 22: Plan Validation — Checking Safety Before Execution Begins

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
    }
}
Enter fullscreen mode Exit fullscreen mode

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"
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

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()
}
Enter fullscreen mode Exit fullscreen mode

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)
                    }
                }
            }
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

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)