fix(hitl): approve/reject continues conversation, persists trace, multi-tool-call decisions - #40
Conversation
…lti-tool-call decisions
The HITL approve/reject flow was broken: clicking approve or reject in the
UI did not continue the conversation. Two root causes:
1. Backend HITL path (SendMessageUseCase) returned the runner Message
directly without persisting trace events. Thread history is rebuilt
exclusively from the trace_events table, so after approve/reject the
refetched history was byte-identical: the AI message kept
status=awaiting_hitl, the HITL panel stayed rendered, and the
continuation produced by Command(resume=...) was invisible.
2. The runner approve_hitl/reject_hitl/edit_hitl ignored multi-tool-call
interrupts: they always sent a single decision, but the langchain
HumanInTheLoopMiddleware groups ALL interrupted tool calls of one AI
message into a single interrupt and requires len(decisions) ==
len(action_requests) (positional). With 2+ interrupted tool calls the
resume raised ValueError -> 500.
Changes:
- Add HitlDecision entity (tool_call_id, action, reason, edits) and
TraceEventType.HITL_DECISION.
- Replace approve_hitl/reject_hitl/edit_hitl on AgentRunner with a single
resume_hitl(thread_id, decisions, turn_id) -> (Message, list[TraceEvent]).
It reads the pending interrupt state, reconstructs the positional
tool_call_id -> action_request mapping (matching langchain after_model
ordering), validates unknown/missing decisions, builds
Command(resume={decisions: [...]}), streams the resume to collect the
trace (HITL_DECISION events + intermediates + trailing AI_MESSAGE), and
returns the final Message + trace.
- SendMessageUseCase HITL path now generates a turn_id, calls resume_hitl,
and persists the trace via trace_repo.add_batch. Legacy single-decision
shape (action + tool_call_id) is converted to a 1-element decisions list.
- ChatRequest accepts a decisions list (mutually exclusive with message /
legacy tool_call_id+action).
- Switch the default checkpointer from memory to postgres (the interrupt
must survive restarts / registry invalidation for resume to work in
multi-worker / durable deployments). The factory falls back to MemorySaver
with a warning if Postgres is unreachable at build time.
- Convert adapter get_state calls to await aget_state (required for the
async Postgres checkpointer).
- README: document the real endpoint (POST /api/v1/chat/{thread_id}) and
the new decisions contract; fix the non-existent /threads/{id}/hitl docs.
Tests: backend suite green (691 passed). 0 new SonarQube issues, 0 new
Trivy vulnerabilities.
Kaiohz
left a comment
There was a problem hiding this comment.
Code review — fix(hitl): approve/reject continues conversation, persists trace, multi-tool-call decisions
Solid refactor. The HITL surface moves from a single-action helper per call to a unified resume_hitl(thread_id, decisions, turn_id) -> (Message, list[TraceEvent]) contract that finally makes multi-tool-call decisions first-class, persists the HITL decision into the trace (was dropped before), and reads state via the async aget_state (required by the new default Postgres checkpointer). Legacy approve_hitl/reject_hitl/edit_hitl are kept as deprecated passthroughs that build a one-element decisions list — no breaking change for the API layer.
What I like
- Domain purity.
HitlDecisionis a Pydantic value object withLiteral["approve","reject","edit"]— typed at the boundary instead of leaking stringly-typed enums into the use case. - Port preserved. Everything still goes through
AgentRunner; the factory and adapter changed, the boundary didn't. - Backward compatibility done right. Legacy callers keep working (use case converts
action+tool_call_id+reason+editsinto a one-element list) without duplicating the resume logic. - Default → Postgres with graceful fallback.
_create_postgres_checkpointerfailure now logsCHECKPOINTER_POSTGRES_UNAVAILABLEand falls back toMemorySaver. Default flips frommemorytopostgresinBackendConfig, aligned with prod on K3s. - Edge cases covered.
no pending interrupt,unknown tool_call_id,missing decision,resume then new interrupt(AWAITING_HITL on the new one, COMPLETED otherwise), multi-decision positional pairing, edit tool-name resolution from the last AI messagetool_calls. Tests assert the actualCommand(resume={"decisions": [...]})payload — that's exactly the contract surface. - Status semantics.
_resolve_statusnow distinguishes "the prior interruption was just decided" (don't re-report AWAITING_HITL) from "a NEW tool_call appeared after the resume" (do). This was the original bug.
Suggestions
_pair_action_requestsindexes by tool name — fragile when two interrupted tool_calls share a name. The langchain middleware filters byinterrupt_onconfig and preserves positional order, but the pairing here matches bytc.get("name") == action_requests[i]["name"]. If an agent emits twosearch(...)tool calls in one AI message and both matchinterrupt_on, the secondtc.get("id")is paired to the secondaction_requestby accident — that's true today because names align, but it would silently swap if the order in the interrupt payload ever differs from the last AI message order. I'd rather pair by position (zip[t for t in tool_calls if t["name"] in {ar["name"] for ar in action_requests}]after an explicit filter), or assert the count.except TypeErrorinresume_hitlis too broad. CatchingTypeErroraround theasync for event in self._collect_trace(...)call to fall back to_build_resume_trace_fallbackwill also mask realTypeErrors raised inside the generator (badevent.typecompare, malformed metadata, etc.) and silently switch to a degraded code path that doesn't emit intermediate trace events. Prefer an explicit capability check:inspect.isasyncgen(self._graph.astream)on a probe, or wrapself._graph.astreamat construction time with ato_async_iterhelper, so the fallback only triggers on "astream returned a non-async-iterable", not on random generator errors.# type: ignore[arg-type]on the legacy path insend_message.py. Whendecisions is None, the code buildsHitlDecision(tool_call_id=tool_call_id, action=action, reason=reason, edits=edits)withaction: str | Nonebeing passed to aLiteralfield. The validator inChatRequestguaranteesaction is not Noneat the HTTP boundary, but the use case is now coupled to that guarantee. Either tighten the type annotation on the use case parameter (action: Literal[...]) orassert action in {...}before the cast so the contract is local.- No test for
asyncio.TimeoutErroron the resume path.invokehastest_invoke_timeout_raises_agent_error.resume_hitlcallsasyncio.wait_for(self._graph.ainvoke(...), timeout=...)in the final fallback but doesn't have a symmetric test — if a future refactor drops the timeout it won't be caught. min_length=1ondecisions. The model validator already raises on emptydecisions, but usingField(default=None, min_length=1)(withdecisions: list[HitlDecision] | None = None) would let pydantic surface a 422 from the OpenAPI schema directly instead of a hand-written message — same end-user UX, less code to maintain.agent_config.pydefault flip.BackendConfig.checkpoint_backenddefault moves from"memory"to"postgres". That's correct for prod, but worth a CHANGELOG / migration note: any local dev setup without a reachable Postgres will start hitting the newCHECKPOINTER_POSTGRES_UNAVAILABLEwarning. Consider a doc snippet or a smoke-test mention in the README.
Score
8/10. The architecture, the tests, and the backward compat are all clean. The two points I'd want a second pass on are the except TypeError swallow (item 2) and the position-by-name pairing (item 1) — both are subtle enough that a regression could pass CI today and bite in prod next quarter.
Non-blocking
- The
agent_crudrename (test naming only) is a no-op reformat — fine. - Adding
HITL_DECISIONtoTraceEventTypeis the right move; worth checking downstream consumers (UI trace viewer, log formatters) still tolerate the new value.
Problem
The HITL (human-in-the-loop) approve/reject flow was broken: clicking Approve or Confirm Reject in the UI did not continue the conversation. Two root causes:
HITL path never persisted trace events.
SendMessageUseCasereturned the runnerMessagedirectly without persisting trace events. Thread history is rebuilt exclusively from thetrace_eventstable, so after approve/reject the refetched history was byte-identical: the AI message keptstatus=awaiting_hitl, the HITL panel stayed rendered, and the continuation produced byCommand(resume=...)was invisible.Multi-tool-call interrupts were broken. The runner
approve_hitl/reject_hitl/edit_hitlalways sent a single decision, but the langchainHumanInTheLoopMiddlewaregroups all interrupted tool calls of one AI message into a single interrupt and requireslen(decisions) == len(action_requests)(positional). With 2+ interrupted tool calls the resume raisedValueError→ 500.A secondary issue: the default
MemorySavercheckpointer lost the pending interrupt across restarts / registry invalidation / multi-worker deployments, turning the resume into a cryptic 500.Changes
New contract
HitlDecisionentity (tool_call_id,action: approve|reject|edit,reason,edits).TraceEventType.HITL_DECISION— new event type persisted for each resume decision.AgentRunner.resume_hitl(thread_id, decisions, turn_id) -> (Message, list[TraceEvent])replaces the 3 old methods. It:await aget_state),tool_call_id → action_requestmapping (matching langchainafter_modelordering),AgentErrorinstead of cryptic 500),Command(resume={decisions: [...]}),HITL_DECISIONevents + intermediates + trailingAI_MESSAGE),Message+ the full trace.SendMessageUseCaseHITL path now generates aturn_id, callsresume_hitl, and persists the trace viatrace_repo.add_batch. Legacy single-decision shape (action+tool_call_id) is converted to a 1-elementdecisionslist (backward compat).ChatRequestaccepts adecisionslist (mutually exclusive withmessage/ legacytool_call_id+action).Checkpointer durability
checkpoint_backendswitched from"memory"to"postgres"(the interrupt must survive restarts for resume to work in durable / multi-worker deployments). The factory falls back toMemorySaverwith a warning if Postgres is unreachable at build time.get_statecalls converted toawait aget_state(required for the async Postgres checkpointer).Deprecated passthroughs
approve_hitl/reject_hitl/edit_hitl) are kept as concrete deprecated passthroughs delegating toresume_hitl, for backward compatibility with any external caller.Docs
POST /api/v1/chat/{thread_id}) and the newdecisionscontract; fixes the non-existent/threads/{id}/hitldocs; updates thecheckpoint_backenddefault topostgres.Tests
TestResumeHitlcovers single/multi/unknown/missing/edit/no-pending;SendMessageUseCaseresume + trace persistence;ChatRequestvalidation; factory checkpointer default + fallback; routes HITL with newresume_hitlmock).uv.lockclean).Acceptance criteria
completed, content non-empty, trace persisted).HITL_DECISIONevent.{tool_call_id, action}payload still works (converted to a 1-element list).AgentError(no more cryptic 500 on double-click / stale panel).Linked PRs
Notes
soludev-compose-apps/bricks/qa/test_hitl_flow.py) are written but must be run after rebuilding the containers with this code. They are expected to fail against the old stack (the regression signal).