Three scenarios require a human action before the run proceeds. Two of
them are approval flows (A / B below) — the agent explicitly waits
for yes/no — and terminate in ControlKind.APPROVE /
ControlKind.REJECT. The third is escalation — goldfive detects
unrecoverable drift and pauses until the operator steers or resumes,
terminating in HUMAN_INTERVENTION_REQUIRED drift (Level 4 of the
intervention ladder).
All three surface through proto events the harmonograf UI (or any
sink) can render. Approvals use ApprovalRequested / ApprovalGranted
/ ApprovalRejected; escalation uses DriftDetected with kind
HUMAN_INTERVENTION_REQUIRED plus the session's
GOLDFIVE_PAUSE_ESCALATE channel dispatch (Phase 2 of #246 replaced the deleted paused_for_human_intervention flag).
The agent explicitly blocks a task pending human yes/no. Works for any adapter
(CallableAdapter, ADK, Claude SDK): the agent calls the 8th canonical reporting
tool, report_awaiting_approval, and its handler awaits on a per-task
asyncio.Event until the control dispatcher resolves it.
agent: call report_awaiting_approval(task_id="t7", prompt="ok to spend $500?")
↓
handler:
steerer.mark_task_blocked(task_id, blocker="awaiting_approval", needed=prompt)
session.pending_approvals[task_id] = asyncio.Event()
session.pending_approvals_meta[task_id] = {"kind": "task", "prompt": ...}
emit(ApprovalRequested{target_id=task_id, kind="task", prompt=...})
await session.pending_approvals[task_id].wait()
decision = session.pending_approvals_meta[task_id]["decision"]
return {"decision": decision, "detail": ...}
↓
UI sees ApprovalRequested → renders [Approve] [Reject]
↓
human clicks → harmonograf ControlEvent(APPROVE|REJECT, payload={"target_id": "t7"})
↓
bridge → ControlChannel → executor drain_controls → dispatch_control
↓
dispatch_control looks up session.pending_approvals["t7"]:
on APPROVE: stores decision="approve", sets event, emits ApprovalGranted.
on REJECT: stores decision="reject", sets event, emits ApprovalRejected.
↓
handler resumes, returns the tool-call ack to the agent.
↓
agent resumes the task. It may call report_task_completed (on approve) or
report_task_failed (on reject) — goldfive does not force either transition;
the agent decides based on the decision it got back from the tool.
Notes:
- The task is transitioned to
BLOCKEDby the handler before it awaits, so observers and UIs have a concrete proto status to render against. - The
ApprovalGranted/ApprovalRejectedevents are a separate channel from the task state machine — they are resolution events, not transitions. A rejected approval does not automatically fail the task; the agent chooses. - No control channel bound (the default
goldfive.wrap()posture —control=None): nothing can ever dispatch APPROVE / REJECT, so the handler returns{"decision": "unavailable", "detail": ...}immediately without blocking the task, registering a waiter, or emittingApprovalRequested. The agent is told approval is unavailable and decides for itself how to continue; goldfive does not force a transition. (Historically this path awaited a waiter that could never be set — the tool call, and the run, hung forever.) - The wait is always finite. An explicit
timeout_ms > 0is honoured verbatim;timeout_ms <= 0(including the omitted default) substitutesSteeringConfig.approval_default_timeout_ms(600 s; envGOLDFIVE_STEER_APPROVAL_DEFAULT_TIMEOUT_MS) instead of the historical unbounded wait — no invocation wall clock covers tool waits, so infinity was a run-hang. - On timeout the handler returns
{"decision": "timeout", "detail": ...}, leaves the task blocked (a later APPROVE / REJECT still resolves viasession.pending_approvals), and emits aHUMAN_INTERVENTION_REQUIREDdrift at WARNING severity (emit-only — no ladder dispatch) so operators see the unresolved approval instead of a silently BLOCKED task.
ADK has its own tool.require_confirmation flag. When a FunctionTool is
constructed with require_confirmation=True, ADK's native behavior is to
short-circuit tool execution, yield an adk_request_confirmation function call
back to the model, and wait for a FunctionResponse carrying a
ToolConfirmation{confirmed=true} before running the tool body.
The goldfive ADKAdapter plugin intercepts that mechanism in
before_tool_callback so the same UI works for both flows:
ADK: model wants to call T (require_confirmation=True)
↓
goldfive _GoldfiveADKPlugin.before_tool_callback:
tool_call_id = tool_context.function_call_id (ADK generates "adk-<uuid>")
session.pending_approvals[tool_call_id] = asyncio.Event()
session.pending_approvals_meta[tool_call_id] = {
"kind": "tool", "tool_name": tool.name, "args": tool_args,
"task_id": session.current_task_id,
}
emit(ApprovalRequested{target_id=tool_call_id, kind="tool", prompt=...,
metadata={"tool_name": ..., "args_json": ...}})
await session.pending_approvals[tool_call_id].wait()
decision = session.pending_approvals_meta[tool_call_id]["decision"]
if decision == "reject":
emit(ApprovalRejected{target_id=tool_call_id, ...})
return {"skipped": True, "reason": "user_rejected"}
emit(ApprovalGranted{target_id=tool_call_id, ...})
return None # fall through; ADK runs the tool
The return shape is the one ADK uses everywhere else in its plugin API:
- Returning a non-None dict tells ADK to skip the tool and use the dict as the
response the model sees (
plugin_manager.run_before_tool_callbackline 512+). - Returning None tells ADK to proceed with the original args.
We bypass ADK's native confirmation flow — the UI we target is goldfive's
approval event, not ADK's adk_request_confirmation function call. The model
never sees the interception; from its perspective the tool just took a while.
Researched against third_party/adk-python/src/google/adk/.
FunctionTool.__init__ accepts a keyword arg require_confirmation:
# src/google/adk/tools/function_tool.py:46-87
def __init__(
self,
func: Callable[..., Any],
*,
require_confirmation: Union[bool, Callable[..., bool]] = False,
):Type: bool | Callable[..., bool]. Stored on the instance as
self._require_confirmation. A callable receives the tool args and returns a
bool — so individual calls can opt in or out dynamically.
Check site: FunctionTool.run_async (function_tool.py:191-220). If the tool is
flagged but tool_context.tool_confirmation.confirmed is not yet set, ADK
returns a structured error + registers a confirmation request on
EventActions.requested_tool_confirmations (keyed by function_call_id).
BasePlugin.before_tool_callback (plugins/base_plugin.py:297-319):
async def before_tool_callback(
self,
*,
tool: BaseTool,
tool_args: dict[str, Any],
tool_context: ToolContext,
) -> Optional[dict]: ...Return contract:
- None → proceed normally; ADK runs
tool.run_async(args=tool_args, tool_context=...). - non-None dict → skip the tool entirely. The dict becomes the tool's response as the model sees it. No exception, no confirmation loop, no double-execution.
You may mutate tool_args in-place before returning None to transform inputs.
The callback is awaited before ADK hits its own require_confirmation check,
so intercepting here cleanly bypasses the native flow.
ADK generates the tool's function-call id in
flows/llm_flows/functions.py:186-187:
def generate_client_function_call_id() -> str:
return f"adk-{uuid}"It is exposed on tool_context.function_call_id. We use this id as
target_id in the ApprovalRequested event — it's stable across the
lifetime of the call and unique within the ADK session.
ToolContext is an alias for Context (tool_context.py:30), defined in
agents/context.py:45. It exposes:
function_call_id: str— theadk-…idtool_confirmation: Optional[ToolConfirmation]— populated when the human-in-the-loop resolution has landed; read-only at runtimestate— the ADK session state dict (we already use this to stash theSessionContextobject that links back to the goldfive Session)
Three new oneof variants on Event.payload (events.proto):
ApprovalRequested approval_requested = 23;
ApprovalGranted approval_granted = 24;
ApprovalRejected approval_rejected = 25;
message ApprovalRequested {
string target_id = 1; // task_id (Flow A) or tool_call_id (Flow B)
string kind = 2; // "task" or "tool"
string prompt = 3;
string task_id = 4; // current task context (empty for root-task approvals)
map<string,string> metadata = 5; // tool_name, args_json, etc.
}
message ApprovalGranted { string target_id = 1; string detail = 2; }
message ApprovalRejected { string target_id = 1; string detail = 2; }Issue #80 presumed APPROVE and REJECT were already in the enum. They are
not — this PR adds them to goldfive.control.ControlKind. Payload shape:
ControlMessage(kind=ControlKind.APPROVE, payload={"target_id": "...", "detail": "..."})
ControlMessage(kind=ControlKind.REJECT, payload={"target_id": "...", "detail": "..."})The target_id routes to the same session.pending_approvals map regardless of
whether it was a task-level or tool-level request.
Unlike flows A and B — which are agent-initiated approvals — flow C is
goldfive-initiated. When the intervention ladder
(DefaultSteerer._ladder_level_for) routes a drift to Level 4
(PAUSE_ESCALATE), the steerer:
- Dispatches a
ControlMessage(kind=GOLDFIVE_PAUSE_ESCALATE, ...)on the boundControlChannel(Phase 2 of #246 — see CANCELLATION-CONTRACT.md §4). The executor's pre-task loop honoursrequest_pause=Truefrom the dispatch outcome, blocking on the channel until aRESUME/STEER/CANCELarrives. - Emits a
DriftDetectedevent of kindHUMAN_INTERVENTION_REQUIRED(proto enum value 37, CRITICAL severity) carryingdetail="escalated from {orig_kind}: {orig_detail}".
Typical Level-4 triggers:
REFINE_VALIDATION_FAILEDat CRITICAL (planner exhausted its retry budget; steerer deliberately does NOT re-refine to avoid an infinite loop).INTENT_DIVERGENCEat CRITICAL (first and repeat).RUNAWAY_DELEGATIONon repeat.- Any
CRITICALdrift whose first-occurrence Level-3 CANCEL_REINVOKE didn't resolve and it repeats.
The Runner's overlay loop blocks before picking up the next step
when a GOLDFIVE_PAUSE_ESCALATE ControlMessage has been drained
from the channel (the dispatch outcome's request_pause=True flips
the executor's pre-task loop into the same blocking wait a user
PAUSE uses). To unpause, the operator sends one of:
ControlKind.RESUME— clear the flag and continue (if the operator believes the drift was spurious).ControlKind.STEER— provide a new direction. The refine runs, the plan is revised, and the overlay restarts with the steer body.ControlKind.CANCEL— abort the run cleanly.
If the pause never resolves and the same HUMAN_INTERVENTION_REQUIRED
drift fires a second time (rare — would require the operator to
send a STEER that re-triggers the same escalation), the ladder
escalates to Level 5 (TERMINATE) — a run-level abort.
Sinks should render the pause as a visible state (badge + paused Gantt) and surface the approve/reject-style buttons as Resume / Steer / Cancel affordances. The UI path mirrors flow A's "awaiting" card shape but with a distinct drift source and a different control verb set.
See DRIFT.md §"Intervention ladder" for the full per-drift-kind level mapping.
The harmonograf UI needs to render ApprovalRequested events with
approve/reject buttons and route clicks through its existing ControlRouter as
ControlEvent(APPROVE|REJECT, payload={"target_id": ...}). Filed separately.