Skip to content

Human in the Loop

Human-in-the-loop patterns let you inject human judgment into automated LLM workflows for high-risk actions, quality review, or mid-execution guidance.

Two Mechanisms

DagNats provides two complementary mechanisms for human interaction:

MechanismWhen to UseHow It Works
Approval GatesBefore a step executesEngine-level pause; step does not start until approved
SignalsDuring a step’s executionHandler-level pause; step blocks on WaitForSignal

Approval gates are best for “should this proceed?” decisions – deploy approvals, spend authorizations, destructive operations. The workflow pauses between steps.

Signals are best for “what should I do next?” decisions – human feedback to a running agent, corrections to generated content, parameter adjustments. The workflow pauses within a step.

Approval Gates for High-Risk Actions

When an LLM agent proposes a destructive action (deleting files, deploying code, sending emails), gate it behind an approval step:

wf := dag.NewWorkflow("agent-with-approval")

agent := wf.AgentLoop("plan", "llm-agent").
    WithMaxIterations(10)

approve := wf.Approval("review-plan", dag.ApprovalConfig{
    Timeout:     4 * time.Hour,
    Subject:     "approval.agent.action",
    Description: "Review agent's proposed changes",
}).After(agent)

execute := wf.Task("execute", "apply-changes").
    After(approve)

def, _ := wf.Build()

The agent loop reasons and produces a plan. The approval gate pauses until a human reviews it. Only after approval does execution proceed. If the human rejects, the approval step fails and the run fails. There is no re-planning route, so to let a reviewer send work back, use a signal inside the agent loop instead.

The approval notification publishes to the NATS subject approval.agent.action. External integrations (Slack, email, dashboard) subscribe and present the decision to the reviewer.

Signal-Based Review Mid-Execution

For interactive feedback during an agent loop, use signals. The agent pauses mid-iteration and waits for input:

w.Handle("llm-agent", func(ctx worker.TaskContext) error {
    var state AgentState
    if saved, _ := ctx.LoadCheckpoint(); saved != nil {
        json.Unmarshal(saved, &state)
    }

    result, _ := callLLM(state.Messages)

    if result.Confidence < 0.7 {
        // Low confidence -- ask for human guidance
        ctx.PutStream([]byte("Low confidence. Requesting review..."))
        review, err := ctx.WaitForSignal("human-review", 1*time.Hour)
        if err != nil {
            return ctx.Fail(err)
        }
        state.Messages = append(state.Messages,
            Message{Role: "user", Content: string(review)},
        )
        data, _ := json.Marshal(state)
        ctx.Checkpoint(data)
        return ctx.Continue(nil)
    }

    if result.Done {
        return ctx.Complete([]byte(result.Answer))
    }
    return ctx.Continue(nil)
})

An external system sends the human’s feedback:

# Via CLI
dagnats run signal <run-id> human-review '{"guidance": "Focus on auth module"}'

Combining Approval with Agent Loops

For maximum safety, use both: signals for in-loop guidance and approval gates before irreversible actions.

wf := dag.NewWorkflow("safe-agent")

// Agent reasons and produces a plan (signals for mid-loop feedback)
agent := wf.AgentLoop("reason", "llm-reason").
    WithMaxIterations(15).
    WithMaxDuration(20 * time.Minute)

// Human reviews the plan before execution
gate := wf.Approval("approve", dag.ApprovalConfig{
    Timeout: 24 * time.Hour,
    Subject: "approval.safe-agent",
}).After(agent)

// Execute the approved plan
execute := wf.Planner("execute", "run-plan", dag.PlannerConfig{
    MaxSteps:     10,
    AllowedTasks: []string{"code-edit", "test-run"},
}).After(gate)

def, _ := wf.Build()

This three-phase pattern (reason, approve, execute) is the safest way to run LLM agents that take real-world actions. The agent can iterate freely during the reasoning phase. The approval gate is the checkpoint before anything irreversible happens.

Timeout Design

ComponentRecommended TimeoutRationale
WaitForSignal in agent loop30-60 minutesHuman may be away; heartbeat to prevent redelivery
Continue(UntilInput())no step timeout; bound with a delayHolds no worker; see Inputs below
Approval gate4-24 hoursAsync review; the step fails on expiry
Per-iteration timeout60-120 secondsPrevent hung LLM calls

For WaitForSignal, remember to call Heartbeat() periodically if the wait exceeds the NATS AckWait period (5 minutes by default). Or use Pause() to NAK with delay and resume when the human responds. Either way the wait counts toward the step’s timeout, so set one that covers the longest expected response.

Inputs for Running Agents

Signals hold one value per name and are never consumed, and WaitForSignal holds a worker for the whole wait. For a conversation with a running agent, use inputs. A caller sends POST /runs/{id}/steps/{step}/inputs with a request_id, a mode (steer or follow_up) and content. The handler moves queued inputs into the conversation with PlaceInputs, and it can wait for the next one with Continue(nil, worker.UntilInput()), which holds no worker while it waits. A repeated request_id is ignored, so a client can retry. Inputs need the step’s journal. See Durable Agents.

Related