Skip to content

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_on config per agent: {"tool_name": true} or {"tool_name": {"allowed_decisions": ["approve","reject"], "when": {...}}}. InterruptConfig is defined in phase 9; this phase extends it with the When clause.
  • 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.
  • HumanInTheLoopMiddleware sits late in the stack: in WrapToolCall, a matching call is persisted as a pending_approvals row and the middleware returns a sentinel error that suspends the run.
  • Run state awaiting_approval added, with awaiting_since and a configurable expiry (HANGO_APPROVAL_TTL, default 24h) after which the run is failed with 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}/approvals accepts one decision per pending call, validates the decision is allowed for that tool, and — in a single transaction — persists the decisions, flips the run awaiting_approval → queued conditionally 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}/approvals lists pending calls with their arguments; GET /v1/approvals?state=pending lists a tenant’s queue.
  • SSE emits approval.requested (with tool name, arguments, allowed decisions) and approval.decided; the stream stays open across suspension so a watching client sees the transition.
  • Subagent inheritance: declarative subagents inherit the parent’s interrupt_on unless 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.
Routeowner / adminmember (incl. run creator)
GET /v1/runs/{id}/approvalsallow (any run in tenant)allow (own runs only)
GET /v1/approvals?state=pendingallow (tenant queue)allow (own runs only)
POST /v1/runs/{id}/approvalsallowdeny (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_tool span ends with hango.tool.outcome=awaiting_approval and a span event tool.approval_requested carrying tool name, call id and allowed decisions; the invoke_agent span ends with hango.run.outcome=awaiting_approval rather than staying open across hours.
  • Resumption starts a new trace whose root invoke_agent span carries a span link to the suspended run’s root span plus hango.run.resumed_from_span_id and hango.run.suspension_seconds; both traces share langfuse.session.id and langfuse.trace.metadata.run_id so Langfuse groups them under one run.
  • Span event tool.approval_decided with hango.approval.decision and the deciding user id; edit decisions record that arguments changed but not the argument content unless content tracing is on.
  • Metrics: hango.approvals.pending gauge (attribute tool), hango.approvals.wait_time histogram, hango.approvals.decisions counter (attributes decision, tool), hango.approvals.expired counter, hango.runs.by_state gains the awaiting_approval state.
  • 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 — subagent interrupt_on inheritance.
  • internal/run/worker.go — persist awaiting_approval, release the slot, re-enqueue on decision; resume path loads decisions.
  • internal/run/emitter.go — approval.requested / approval.decided event 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 and awaiting_since.
  • internal/config/config.go — HANGO_APPROVAL_TTL, HANGO_APPROVAL_SWEEP_INTERVAL.
  • internal/store/pg/tenancy_test.go — add pending_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

  1. Write migrations/00010_pending_approvals.sql, including the runs.state constraint change and awaiting_since.
  2. Write the scoped ApprovalStore: CreateBatch, ListPending(runID), ListPendingByTenant, Decide(id, decision, args, message, userID), ExpireOlderThan.
  3. Write internal/agent/when.go: a small matcher supporting exact and prefix matches on argument fields plus a size threshold, so rules like “interrupt write_file only under /memories/” are expressible without code.
  4. Write internal/agent/mwhitl.go: in WrapToolCall, first check State.Private for a decision already loaded for this tool_call_id. If present, apply it (approve → next; edit → validate the supplied arguments against tool.Parameters() and re-run the rule’s when matcher, then next with 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 return ErrAwaitingApproval.
  5. 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, persist State to runs.state_json, and return StatusAwaitingApproval without persisting any partial tool results for the batch.
  6. Extend the worker: on StatusAwaitingApproval, set the run to awaiting_approval with awaiting_since, emit approval.requested events, end the run span with the suspension outcome, and release the lane slot.
  7. Write the approval handlers: authorization per the matrix above (owner/admin only for decisions, never a member even as run creator), decision validation against allowed_decisions, edit-argument schema and when validation, all-or-nothing completeness check returning 409 with outstanding calls, then one transaction that stores the decisions and moves the run awaiting_approval → queued conditionally; enqueue after commit (deduplicated). Publish approval.decided through the broadcaster from the handler. Call SetAuditAction(c, "approval.decide", "pending_approval", approvalID) with decided_by, the decision kind, the tool name and the tool_call_id in detail; a 403 or a 409 is audited too, with outcome denied or error.
  8. Extend the resume path: the worker loads decided approvals for the run into State.Private before Chain.BeforeRun, RepairToolCallsMiddleware skips tool_call_ids that have a decided approval, and the loop re-drives the pending batch through Chain.ToolCall before its next model call. A tool call that was never decided (for example after an expiry-triggered failure) is still repaired normally.
  9. Write internal/run/approval_sweeper.go as a safego ticker expiring approvals past the TTL. Each expiry is one transaction: mark the approvals expired and move the run to failed conditionally on it still being awaiting_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 own audit_events row per expired approval directly, with actor_kind = 'system' and action approval.expire.
  10. Wire subagent inheritance in the child builder and verify a suspension inside a child suspends the parent run with subagent_call_id recorded.
  11. Keep the SSE handler open across suspension: treat awaiting_approval as non-terminal and continue streaming.
  12. Add the instrumentation, including the span link on resume.
  13. 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 ; an edit decision with arguments violating the tool schema returns 400 and leaves the approval pending, and one that moves the call outside the when scope is rejected ; when matcher scopes an interrupt to a path prefix; a write_file to /memories/ interrupts by default ; expiry fails the run; SSE client observes approval.requested and then approval.decided published by the handler on one connection; subagent suspension and resumption; pending_approvals cross-tenant and intra-tenant cases.
  14. Add the approvals alert rules to deploy/grafana/provisioning/alerting.yaml (pending-gauge growth, expiry rate) and the approvals panels to hango-runs.
  15. 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 ToolWrapper returning 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 ApprovalAware setter interface on tools with in-process wiring, not a persisted suspend/resume, so a restart loses pending approvals.
  • deepagents comparison: interrupt_on with approve|edit|reject|respond, HumanInTheLoopMiddleware late in the stack, FilesystemPermission{mode:"interrupt"} auto-generating entries merged with user config (user wins per tool name), a checkpointer requirement, resumption via Command(resume={"decisions":[...]}) on the same thread_id, and declarative subagents inheriting interrupt_on while 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.pending by tool shows what humans are blocking on; hango.approvals.wait_time shows whether the queue is being serviced; the two linked traces (suspended and resumed) show exactly what the human changed, because edit decisions are recorded as span events and as rows; Loki filtered by hango.run.id gives 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_file to interrupt, approve one call and edit another, then reject a third and watch the model recover; try to approve your own run as a member and 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_approval and no tool in that batch executed (counting-fake-tool assertion).
  • Each of approve, edit, reject, respond resumes 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 RepairToolCalls does 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 edit decision with schema-invalid arguments returns 400 and leaves the approval pending; one that escapes the rule’s when scope 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 emits run.finished.
  • Every approval outcome is audited: an approve/edit/reject/respond decision writes an approval.decide row carrying decided_by and the decision kind; a member’s 403 writes one with outcome denied; a sweeper expiry writes an approval.expire row with actor_kind = 'system'. No row contains the tool arguments.
  • One SSE connection observes approval.requested, then approval.decided published 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_file to /memories/AGENTS.md suspends for approval.
  • A subagent-level interrupt suspends the parent run with subagent_call_id set and resumes into the child.
  • In Tempo, the suspended and resumed traces are linked and hango.run.suspension_seconds is recorded; in Langfuse both appear under the same session; hango.approvals.* series exist.

Risk Assessment

RiskLikelihood x ImpactSignalPre-decided response
Resume re-executes side-effecting toolsMedium x CriticalDuplicate writes after approvalBatch-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/attentionHigh x Mediumhango.approvals.pending growing without boundTTL 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 failureHigh x HighProvider 400 on the next run in that sessionRepair 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 resumeMedium x CriticalApproved 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 looseMedium x HighA user approves their own privileged actionowner/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, raceMedium x HighA run executed twice, or expired and resumedDecision 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 continuityMedium x LowOrphan traces in TempoSpans are closed at suspension and the resumed trace carries a span link plus shared Langfuse session id
SSE connection idles for hours during suspensionMedium x MediumProxy kills the streamKeepalive 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.