Human-in-the-loop
Overview
Make the loop suspendable and resumable from persisted state. Per-tool interrupt_on configuration causes a matching tool call to be persisted as a pending approval; the run transitions to awaiting_approval and the worker releases its slot. A human then calls POST /v1/runs/{id}/approvals with approve, edit, reject or respond decisions, and the run is re-enqueued and continues from exactly where it stopped. This is the phase that validates every earlier durability decision: append-only messages, persisted run state, the event log and dangling-tool-call repair.
Requirements
Functional
interrupt_onconfig per agent:{"tool_name": true}or{"tool_name": {"allowed_decisions": ["approve","reject"], "when": {...}}}.InterruptConfigis defined in phase 9; this phase extends it with theWhenclause.- Default interrupt rules that user config may relax but not silently remove: `write_file` and `edit_file` targeting `/memories/` or `/skills/`. They are merged in with user config winning per tool name, and removing them requires an explicit `"interrupt_on": {"write_file": false}` that is logged at agent save time. This is what replaces the phase-8 interim rule that made those prefixes API-only.
HumanInTheLoopMiddlewaresits late in the stack: inWrapToolCall, a matching call is persisted as apending_approvalsrow and the middleware returns a sentinel error that suspends the run.- Run state
awaiting_approvaladded, withawaiting_sinceand a configurable expiry (HANGO_APPROVAL_TTL, default 24h) after which the run isfailedwith a clear reason by a sweeper. - All tool calls in the same batch are persisted together: the batch is suspended atomically, so no tool in a batch executes while a sibling awaits approval.
- Decisions:
approve(execute as requested),edit(execute with the human-supplied arguments, and record both),reject(return a tool error result with the human’s message and let the model continue),respond(skip execution and return the human’s text as the tool result). - An `edit` decision's arguments are **validated against the tool's `Parameters()` JSON Schema and re-run through the rule's `when` matcher** before execution. A human (or a compromised session) must not be able to hand a tool arguments the model could never have produced, nor edit a call out of the scope that made it interruptible in the first place. A failing validation returns 400 with the schema error and leaves the approval pending.
POST /v1/runs/{id}/approvalsaccepts one decision per pending call, validates the decision is allowed for that tool, and — in a single transaction — persists the decisions, flips the runawaiting_approval → queuedconditionally on its current state, and returns. Enqueue happens after commit and is deduplicated by run id. Partial approval (some calls decided) returns 409 with the outstanding list. Two concurrent requests that would each complete the final decision therefore produce exactly one state transition and exactly one execution; the loser sees the conflict and returns 409.- The handler publishes `approval.decided` through the broadcaster itself, so a client watching the SSE stream sees the decision immediately even though no worker holds the run at that moment. Waiting for the resumed worker to emit it would leave a visible gap of seconds to minutes.
GET /v1/runs/{id}/approvalslists pending calls with their arguments;GET /v1/approvals?state=pendinglists a tenant’s queue.- SSE emits
approval.requested(with tool name, arguments, allowed decisions) andapproval.decided; the stream stays open across suspension so a watching client sees the transition. - Subagent inheritance: declarative subagents inherit the parent’s
interrupt_onunless they declare their own; a subagent suspension suspends the whole run, and resumption re-enters the child at the pending call.
Non-functional
- **Resume order is load-decisions-then-`BeforeRun`.** The worker loads the run's `decided` approvals into `State.Private` *before* the chain's `BeforeRun` hooks execute. `RepairToolCallsMiddleware` then **skips any `tool_call_id` that has a decided pending approval**: those calls are not dangling, they are about to be executed. Without this ordering, repair sees the suspended batch's unmatched `tool_use` blocks, synthesizes "tool call interrupted" error results for them, and the approval the human just granted is destroyed before it is used.
- **Resume re-drives the pending batch through the tool chain**; it does not re-enter at a model call. The loop's resume entry point takes the pending batch, runs each call through `Chain.ToolCall` (where `HITLMiddleware` finds the decision and applies it), persists the resulting tool-result message, and only then continues to the next model call. Re-entering at a model call would either re-ask the model (duplicating the batch) or send a request whose last assistant message has unmatched `tool_use` blocks.
Resumption must not re-execute already-completed tool calls: the resumed loop reconstructs state from persisted messages plus
runs.state_json, and the repair middleware fills only calls that are genuinely dangling and undecided.The worker slot is released during suspension; a suspended run holds no goroutine, no memory registry entry and no database connection.
- Approval authorization matrix: only an `owner` or `admin` of the run's tenant may decide, **and never the creator of the interrupting run unless that user is an `owner` or `admin`**. Self-approval defeats the entire control: the point of an interrupt is that a second party reviews a privileged action. A `member` — including the run's creator — always gets 403 on `POST /v1/runs/{id}/approvals`. `GET /v1/runs/{id}/approvals` follows the ordinary run visibility rule (creator-scoped for `member`), so a creator can see what is pending without being able to decide it. Every decision records `decided_by`.
- Every approval outcome is audited, not just logged. A human decision writes an `audit_events` row with `actor_kind = 'user'`, action `approval.decide`, `target_kind = 'pending_approval'`, the approval id as `target_id`, and `detail` carrying `decided_by`, the decision kind, the tool name and the `tool_call_id` — never the arguments. An expiry written by the sweeper writes the same shape with `actor_kind = 'system'`, `actor_id` null and action `approval.expire`, because "nobody decided, and the system gave up" is exactly the answer a reviewer needs when a run failed overnight. Both rows carry the `trace_id` of the trace that produced them, so they join the suspended and resumed run traces.
| Route | owner / admin | member (incl. run creator) |
|---|---|---|
GET /v1/runs/{id}/approvals | allow (any run in tenant) | allow (own runs only) |
GET /v1/approvals?state=pending | allow (tenant queue) | allow (own runs only) |
POST /v1/runs/{id}/approvals | allow | deny (403) |
- Cross-tenant and intra-tenant isolation tests for
pending_approvals.
Architecture
// internal/agent/mwhitl.go
// InterruptConfig is declared in phase 9 (internal/agent/subagent.go); this phase adds When.
// Red team: F15
type InterruptConfig struct {
AllowedDecisions []string `json:"allowed_decisions,omitempty"` // default all four
When *WhenClause `json:"when,omitempty"` // arg matchers
}
type Decision struct {
Kind string // approve|edit|reject|respond
Args json.RawMessage // edit only
Message string // reject|respond
DecidedBy uuid.UUID
}
type HITLMiddleware struct {
Config map[string]InterruptConfig
Store store.ApprovalStore
} // ToolWrapper
var ErrAwaitingApproval = errors.New("run suspended awaiting approval")
// internal/agent/loop.go (extended)
type Status uint8 // StatusDone, StatusFailed, StatusCancelled, StatusAwaitingApproval
// Run returns StatusAwaitingApproval without error; the worker persists and releases the slot.
// Red team: F3 — on resume the worker loads decisions into State.Private BEFORE Chain.BeforeRun,
// RepairToolCalls skips decided tool_call_ids, and the loop re-drives the pending batch through
// Chain.ToolCall rather than re-entering at a model call.-- migrations/00010_pending_approvals.sql
create table pending_approvals (
id uuid primary key,
tenant_id uuid not null references tenants(id) on delete cascade,
run_id uuid not null references runs(id) on delete cascade,
turn int not null,
tool_call_id text not null,
tool_name text not null,
arguments jsonb not null,
allowed_decisions text[] not null,
subagent_call_id uuid references subagent_calls(id) on delete cascade,
state text not null check (state in ('pending','decided','expired')),
decision text not null default '',
decision_args jsonb,
decision_message text not null default '',
decided_by uuid references users(id),
decided_at timestamptz,
created_at timestamptz not null default now(),
unique (run_id, tool_call_id)
);
create index pending_approvals_tenant_state_idx on pending_approvals (tenant_id, state, created_at);
create index pending_approvals_tenant_run_idx on pending_approvals (tenant_id, run_id);
alter table runs drop constraint runs_state_check;
alter table runs add constraint runs_state_check check (state in ('queued','running','awaiting_approval','succeeded','failed','cancelled'));
alter table runs add column awaiting_since timestamptz;Instrumentation
- The suspension is explicit in the trace: the
execute_toolspan ends withhango.tool.outcome=awaiting_approvaland a span eventtool.approval_requestedcarrying tool name, call id and allowed decisions; theinvoke_agentspan ends withhango.run.outcome=awaiting_approvalrather than staying open across hours. - Resumption starts a new trace whose root
invoke_agentspan carries a span link to the suspended run’s root span plushango.run.resumed_from_span_idandhango.run.suspension_seconds; both traces sharelangfuse.session.idandlangfuse.trace.metadata.run_idso Langfuse groups them under one run. - Span event
tool.approval_decidedwithhango.approval.decisionand the deciding user id;editdecisions record that arguments changed but not the argument content unless content tracing is on. - Metrics:
hango.approvals.pendinggauge (attributetool),hango.approvals.wait_timehistogram,hango.approvals.decisionscounter (attributesdecision,tool),hango.approvals.expiredcounter,hango.runs.by_stategains theawaiting_approvalstate. - Log fields: approval request and decision log at info with run id, tool, call id, decision and deciding user — the audit trail a reviewer will actually read.
Related Code Files
Create
internal/agent/mwhitl.go,internal/agent/mwhitl_test.go,internal/agent/when.go(argument matchers) and its test.internal/store/pg/approval.go,internal/store/pg/approval_test.go.internal/server/handlers_approvals.go.internal/run/approval_sweeper.go— expires stale pending approvals and fails their runs.migrations/00010_pending_approvals.sql.docs/learning/15-human-in-the-loop.md.
Modify
internal/agent/loop.go—StatusAwaitingApproval, decision consumption before suspension, batch-atomic suspension.internal/agent/mwstack.go— fill the HumanInTheLoop slot (after Memory, before the allowlist) and merge filesystem interrupt rules with user config, user winning per tool name; update the pinned order test.internal/agent/childbuilder.go— subagentinterrupt_oninheritance.internal/run/worker.go— persistawaiting_approval, release the slot, re-enqueue on decision; resume path loads decisions.internal/run/emitter.go—approval.requested/approval.decidedevent kinds.internal/server/handlers_run_events.go— keep the stream open across suspension instead of closing on a non-running state.internal/store/pg/run.go— the new state andawaiting_since.internal/config/config.go—HANGO_APPROVAL_TTL,HANGO_APPROVAL_SWEEP_INTERVAL.internal/store/pg/tenancy_test.go— addpending_approvals(cross-tenant and intra-tenant).internal/server/authz.go— the approvals rows of the authorization matrix.deploy/grafana/provisioning/alerting.yaml,deploy/grafana/dashboards/hango-runs.json— approvals alert and panels.docs/system-architecture.md,docs/learning/README.md.
Implementation Steps
- Write
migrations/00010_pending_approvals.sql, including theruns.stateconstraint change andawaiting_since. - Write the scoped
ApprovalStore:CreateBatch,ListPending(runID),ListPendingByTenant,Decide(id, decision, args, message, userID),ExpireOlderThan. - Write
internal/agent/when.go: a small matcher supporting exact and prefix matches on argument fields plus a size threshold, so rules like “interruptwrite_fileonly under/memories/” are expressible without code. - Write
internal/agent/mwhitl.go: inWrapToolCall, first checkState.Privatefor a decision already loaded for thistool_call_id. If present, apply it (approve→next;edit→ validate the supplied arguments againsttool.Parameters()and re-run the rule’swhenmatcher, thennextwith the replaced arguments, recording both argument sets;reject→ error result with the message;respond→ non-error result with the message) and record the span event. If absent and the tool matches an interrupt rule, register the call in a per-batch pending list and returnErrAwaitingApproval. - Make suspension batch-atomic in
loop.go: run the batch’s tool calls through the chain collecting either results or pending registrations; if any registration exists, persist all pending rows in one transaction, persistStatetoruns.state_json, and returnStatusAwaitingApprovalwithout persisting any partial tool results for the batch. - Extend the worker: on
StatusAwaitingApproval, set the run toawaiting_approvalwithawaiting_since, emitapproval.requestedevents, end the run span with the suspension outcome, and release the lane slot. - Write the approval handlers: authorization per the matrix above (
owner/adminonly for decisions, never amembereven as run creator), decision validation againstallowed_decisions,edit-argument schema andwhenvalidation, all-or-nothing completeness check returning 409 with outstanding calls, then one transaction that stores the decisions and moves the runawaiting_approval → queuedconditionally; enqueue after commit (deduplicated). Publishapproval.decidedthrough the broadcaster from the handler. CallSetAuditAction(c, "approval.decide", "pending_approval", approvalID)withdecided_by, the decision kind, the tool name and thetool_call_idindetail; a 403 or a 409 is audited too, with outcomedeniedorerror. - Extend the resume path: the worker loads decided approvals for the run into
State.PrivatebeforeChain.BeforeRun,RepairToolCallsMiddlewareskipstool_call_ids that have a decided approval, and the loop re-drives the pending batch throughChain.ToolCallbefore its next model call. A tool call that was never decided (for example after an expiry-triggered failure) is still repaired normally. - Write
internal/run/approval_sweeper.goas asafegoticker expiring approvals past the TTL. Each expiry is one transaction: mark the approvalsexpiredand move the run tofailedconditionally on it still beingawaiting_approval, so a decision landing at the same instant either wins or sees the conflict — never both. Emit the terminal events after commit. The sweeper has no request and therefore no middleware: it appends its ownaudit_eventsrow per expired approval directly, withactor_kind = 'system'and actionapproval.expire. - Wire subagent inheritance in the child builder and verify a suspension inside a child suspends the parent run with
subagent_call_idrecorded. - Keep the SSE handler open across suspension: treat
awaiting_approvalas non-terminal and continue streaming. - Add the instrumentation, including the span link on resume.
- Tests: interrupt triggers suspension and no tool executed in that batch; each of the four decisions produces the correct tool result and the run completes; an approved tool executes exactly once on resume, asserted with a counting fake tool, with repair proven not to have replaced it (the approval-destroying regression test) ; two concurrent requests each completing the final decision produce exactly one run transition and exactly one execution ; a decision racing the expiry sweeper resolves to exactly one outcome; partial approval returns 409; a
member, including the run’s creator, gets 403 on decide and 200 on list for their own run ; aneditdecision with arguments violating the tool schema returns 400 and leaves the approval pending, and one that moves the call outside thewhenscope is rejected ;whenmatcher scopes an interrupt to a path prefix; awrite_fileto/memories/interrupts by default ; expiry fails the run; SSE client observesapproval.requestedand thenapproval.decidedpublished by the handler on one connection; subagent suspension and resumption;pending_approvalscross-tenant and intra-tenant cases. - Add the approvals alert rules to
deploy/grafana/provisioning/alerting.yaml(pending-gauge growth, expiry rate) and the approvals panels tohango-runs. - Update docs and write the learning doc.
Learning Doc Outline
docs/learning/15-human-in-the-loop.md must contain:
- Concept: why HITL forces the loop to be a resumable state machine rather than a blocking function, and why every earlier durability decision is what makes it cheap here.
- Design choices: interrupt as a
ToolWrapperreturning a sentinel, batch-atomic suspension, four decision kinds, decisions loaded into state and consumed by the same middleware, slot released during suspension, expiry sweeper, span links across the suspension boundary. - Trade-offs: sentinel error vs a status return from the chain; suspending the whole batch vs executing safe siblings first (chose atomic: partial execution makes rejection semantics undefinable); polling vs SSE for approval notification; TTL expiry vs indefinite waiting.
- Why the run state machine now has six states and what guards each transition.
- goclaw comparison: approval exists as an
ApprovalAwaresetter interface on tools with in-process wiring, not a persisted suspend/resume, so a restart loses pending approvals. - deepagents comparison:
interrupt_onwithapprove|edit|reject|respond,HumanInTheLoopMiddlewarelate in the stack,FilesystemPermission{mode:"interrupt"}auto-generating entries merged with user config (user wins per tool name), a checkpointer requirement, resumption viaCommand(resume={"decisions":[...]})on the samethread_id, and declarative subagents inheritinginterrupt_onwhile compiled ones never do. - Why hango diverges: approvals are rows with an owner, an audit trail and an expiry, not checkpoint state; resumption is an authenticated API call, not a client-side command object.
- How to debug this layer:
hango.approvals.pendingby tool shows what humans are blocking on;hango.approvals.wait_timeshows whether the queue is being serviced; the two linked traces (suspended and resumed) show exactly what the human changed, becauseeditdecisions are recorded as span events and as rows; Loki filtered byhango.run.idgives the full audit narrative including the deciding user. - The resume ordering constraint (decisions → repair → re-drive the batch) as the single subtlest thing in the whole roadmap, and what each wrong ordering breaks.
- Exercises: configure
write_fileto interrupt, approve one call and edit another, then reject a third and watch the model recover; try to approve your own run as amemberand read the 403. - Why the approval log is not enough and the audit row is the artifact: a log line is best-effort, unindexed and subject to retention; an `audit_events` row is queryable by tenant and target, joins to its trace, and survives the log pipeline.
- Time budget: 12h implementation + 2h learning doc.
Success Criteria
-
go build ./... && go vet ./... && go test ./...pass. - A configured tool suspends the run to
awaiting_approvaland no tool in that batch executed (counting-fake-tool assertion). - Each of
approve,edit,reject,respondresumes the run and produces the documented tool result (four tests). - Partial approval returns 409 listing outstanding calls; a
member— including the run’s creator — gets 403 on decide. - An approved tool executes exactly once after resume (counting fake tool), and
RepairToolCallsdoes not replace it with an “interrupted” result. - Two concurrent final approvals produce exactly one run transition and exactly one execution; the loser gets 409.
- An
editdecision with schema-invalid arguments returns 400 and leaves the approval pending; one that escapes the rule’swhenscope is rejected. - A suspended run occupies no lane slot and no goroutine (leak test plus lane stats assertion).
- A resumed run does not re-execute completed tool calls (test).
- Expiry sweeper fails a stale run with
"approval expired"and emitsrun.finished. - Every approval outcome is audited: an
approve/edit/reject/responddecision writes anapproval.deciderow carryingdecided_byand the decision kind; amember’s 403 writes one with outcomedenied; a sweeper expiry writes anapproval.expirerow withactor_kind = 'system'. No row contains the tool arguments. - One SSE connection observes
approval.requested, thenapproval.decidedpublished by the handler at decision time (not minutes later when a worker picks the run up), then the rest of the run. - With no user configuration, a
write_fileto/memories/AGENTS.mdsuspends for approval. - A subagent-level interrupt suspends the parent run with
subagent_call_idset and resumes into the child. - In Tempo, the suspended and resumed traces are linked and
hango.run.suspension_secondsis recorded; in Langfuse both appear under the same session;hango.approvals.*series exist.
Risk Assessment
| Risk | Likelihood x Impact | Signal | Pre-decided response |
|---|---|---|---|
| Resume re-executes side-effecting tools | Medium x Critical | Duplicate writes after approval | Batch-atomic suspension means no result from that batch was persisted, and decisions are keyed by tool_call_id; the counting-fake-tool test is the gate |
| Runs suspend forever and leak quota/attention | High x Medium | hango.approvals.pending growing without bound | TTL expiry sweeper plus the pending gauge on the dashboard and an alert in the phase-14 rule set |
| Dangling tool calls after an expiry-driven failure | High x High | Provider 400 on the next run in that session | Repair middleware (phase 7) synthesizes error tool results for undecided calls only; a test drives expiry then a new run in the same session |
| Repair destroys the batch it was supposed to resume | Medium x Critical | Approved tool never executes; model sees “tool call interrupted” | Decisions are loaded before BeforeRun, repair skips decided tool_call_ids, and resume re-drives the batch through the tool chain; the exactly-once test is the gate |
| Decision authorization too loose | Medium x High | A user approves their own privileged action | owner/admin only, and never the run’s creator unless they hold one of those roles; enforced in authz.go, tested, and every decision records decided_by |
| A decision and an expiry, or two decisions, race | Medium x High | A run executed twice, or expired and resumed | Decision and expiry each transition the run conditionally inside the same transaction as their own write; the concurrent-approval test is a success criterion |
| Long suspension breaks trace continuity | Medium x Low | Orphan traces in Tempo | Spans are closed at suspension and the resumed trace carries a span link plus shared Langfuse session id |
| SSE connection idles for hours during suspension | Medium x Medium | Proxy kills the stream | Keepalive pings continue during suspension; clients are told to reconnect with Last-Event-ID, which still works because the event log is durable |
Rollback
goose down one step drops pending_approvals, runs.awaiting_since and restores the five-state constraint (any run in awaiting_approval must be failed first; the down migration does this explicitly). Removing the HITL slot from DefaultStack disables interrupts entirely and the loop returns to phase 14 behaviour.