goldfive runs are not fire-and-forget. While the executor walks the plan, an external operator β a harmonograf UI user, a CLI, a test harness β can reach in and redirect it: pause between tasks, cancel the run, steer the planner into a different shape, rewind to an earlier task, approve or reject a tool call waiting for a human.
Every one of those verbs travels through a single primitive: the
ControlChannel. This document is the canonical reference for
that primitive β what it is, what every verb means, how each one
lands in the executor, and how a UI on the other side of a network
hooks into it.
Type-system view (enum tables, bridge tables, severity ladder) is in VOCABULARY.md. Why-this-shape is in RATIONALE.md. This doc focuses on the mechanics.
Three forces made live steering first-class.
Long runs. A slide-deck agent may take ten minutes end-to-end. An operator watching the harmonograf Gantt often realises at minute three that the plan is heading the wrong way. Mid-run steering replaces remaining work without losing what's already completed.
Human-in-the-loop approval. Some tool calls must not land without
a human yes/no β a billing call, a DELETE FROM, a production deploy.
goldfive cannot reach the UI directly; the UI cannot touch goldfive-
internal state directly. The ControlChannel plus
session.pending_approvals is that bridge.
Tests and CLIs. The same primitive that drives the UI drives test
harnesses. Asserting "PAUSE emits USER_PAUSE" shouldn't need a UI
server. ControlChannel is framework-neutral; tests instantiate one
directly.
The design constraint: no polling, anywhere. Both sides await
queues (asyncio.Queue in-process, gRPC streams cross-process).
The on-the-wire control plane (ControlEvent, ControlKind,
ControlAckResult, ControlAck, the typed payload oneof) is defined
once, in proto/goldfive/v1/control.proto. harmonograf, any other
bridge, and any future non-Python language binding imports goldfive's
proto rather than declaring its own. The bridge becomes a transport
relay β it moves ControlEvent bytes from the UI process into the
goldfive process and hands the already-decoded message to
ControlChannel.send. No enum translation. No per-kind JSON schema.
This was not always true. Harmonograf used to define a parallel
ControlEvent / ControlKind in its own proto and the
harmonograf_client bridge hand-maintained a _KIND_MAP. That drifted
twice β once after goldfive added APPROVE / REJECT (harmonograf #34) β
so in issue #88 the control types moved under goldfive.v1. The
Python dataclasses in goldfive/control.py stay as an ergonomic
in-process surface; the converters in goldfive/conv.py
(to_pb_control_event / from_pb_control_event / to_pb_control_ack
/ from_pb_control_ack) round-trip between them. A tripwire test
asserts the Python ControlKind StrEnum and the proto enum stay in
lockstep; adding a member to one without the other fails CI.
Typed payloads replace what was previously bytes payload. Each kind
that carries data has a dedicated message (SteerPayload,
RewindPayload, ApprovePayload, RejectPayload,
InjectMessagePayload) selected by a oneof. The dataclass keeps
payload: dict[str, Any] for Python-side ergonomics; the converter is
the boundary that maps dict β oneof branch and back.
SteerPayload (proto/goldfive/v1/control.proto) carries four
strings:
| Field | # | Meaning |
|---|---|---|
note |
1 | The free-text steering instruction the operator authored. |
suggested_action |
2 | Optional short verb the UI may surface as a hint (e.g. "drop research phase"). |
author |
3 | Operator identity from the originating annotation. Empty when the bridge doesn't source annotations. Used for audit trails and prompt attribution (appears in the USER_STEER drift detail as "by {author}: {note}"). |
annotation_id |
4 | Source annotation id. Used for idempotency (see Β§1.c) and for stamping DriftDetected.annotation_id on the resulting USER_STEER drift so sinks can dedup against the annotation row. |
Bridges that don't source annotations may leave author and
annotation_id empty β the in-process dedupe falls back to
ControlEvent.id / ControlMessage.id.
Every STEER carries a dedupe id: annotation_id when the bridge
populated one, otherwise the ControlMessage.id. DefaultSteerer
records processed ids on
session.state["goldfive.processed_steer_ids"] (a bounded FIFO) and
drops retries before they reach steerer._handle_drift:
- Retries of the same STEER (same annotation, repeated
channel.send) β dropped silently; the steerer does not cascade- cancel or callplanner.refinea second time. - New STEER with a fresh id β processed normally; emits USER_STEER drift, cancels the in-flight invocation, runs refine.
- Bridge that doesn't source annotation_id β falls back to the
ControlMessage.id; retries at the transport layer still dedupe because the bridge re-uses the same message id.
Dispatcher and ack still fire on every delivery (so the bridge sees
an AckResult.SUCCESS back) β only the side-effects (drift + refine)
are suppressed for duplicates.
One class, two queues, a dozen lines of interface. Lives at
goldfive/control.py and re-exports at the package root
(goldfive.ControlChannel).
from goldfive import ControlChannel, ControlMessage, ControlKind, ControlAck, AckResult
channel = ControlChannel()
# Sender side (UI bridge, CLI, test)
await channel.send(ControlMessage(kind=ControlKind.PAUSE))
# Runner-internal side (executor)
msg = await channel.receive(timeout=0.1)
if msg is not None:
await channel.ack(ControlAck(control_id=msg.id, result=AckResult.SUCCESS))
# Sender side: drain acks
async for ack in channel.acks():
... # surface to the UIclass ControlKind(StrEnum):
PAUSE = "PAUSE"
RESUME = "RESUME"
CANCEL = "CANCEL"
REWIND_TO = "REWIND_TO" # payload: {"task_id": "..."}
STEER = "STEER" # payload: {"note": "...", "suggested_action": "..."}
APPROVE = "APPROVE" # payload: {"target_id": "...", "detail": "..."}
REJECT = "REJECT" # payload: {"target_id": "...", "detail": "..."}
STATUS_QUERY = "STATUS_QUERY" # no payload
INTERCEPT_TRANSFER = "INTERCEPT_TRANSFER" # payload: {"enabled": bool}
INJECT_MESSAGE = "INJECT_MESSAGE" # payload: {"role": "...", "text": "..."}
class AckResult(StrEnum):
SUCCESS = "SUCCESS"
FAILURE = "FAILURE"
UNSUPPORTED = "UNSUPPORTED"
@dataclass
class ControlMessage:
kind: ControlKind
id: str = field(default_factory=lambda: uuid.uuid4().hex)
payload: dict[str, Any] = field(default_factory=dict)
issued_at_ms: int = 0
@dataclass
class ControlAck:
control_id: str
result: AckResult
detail: str = ""
acked_at_ms: int = 0Every member above has a matching CONTROL_KIND_<NAME> value in
proto/goldfive/v1/control.proto; the alignment test in
tests/test_control_proto.py keeps the two in lockstep.
Bidirectional but asymmetric. Two internal queues: _inbox
(external senders push, runner consumes) and _outbox (runner
pushes, external consumers iterate). Both sides await; neither
polls.
Every message produces exactly one ack. The executor dispatches, publishes the ack, then applies the effect. Errors after the ack surface as proto events, not late acks.
IDs are UUIDs, not sequence numbers. Senders correlate
ControlAck.control_id back to ControlMessage.id. Acks land in
send order in practice because the runner consumes the inbox
sequentially, but the contract imposes no ordering beyond that.
Close is idempotent. channel.close() marks both queues closed.
receive() returns None; acks() exits via an internal sentinel.
In-flight messages drain first.
The channel is constructed by whoever owns the external end and
passed to the Runner via control=:
from goldfive import Runner, ControlChannel, SequentialExecutor
channel = ControlChannel()
runner = Runner(
agent=...,
planner=...,
executor=SequentialExecutor(),
control=channel,
)The Runner forwards control=channel into executor.run(...). Both
SequentialExecutor and ParallelDAGExecutor drain it between tasks
and race it against the adapter via asyncio.wait(..., return_when=FIRST_COMPLETED) for mid-task cancel/steer.
Every arrow below is an await or a proto event on the wire;
nothing polls.
βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
β browser (harmonograf UI) β
β user clicks [Steer]; form = {note: "focus on slide 3", ...} β
ββββββββββββββββββββββββββββββββββββ¬βββββββββββββββββββββββββββββββββ
β gRPC-Web β ControlEvent(STEER)
ββββββββββββββββββββββββββββββββββββΌβββββββββββββββββββββββββββββββββ
β harmonograf server (Python, gRPC :7531) β
β looks up the Client for this run_id; writes onto its control β
β stream β
ββββββββββββββββββββββββββββββββββββ¬βββββββββββββββββββββββββββββββββ
β gRPC server-streaming
ββββββββββββββββββββββββββββββββββββΌβββββββββββββββββββββββββββββββββ
β harmonograf_client.Client (in goldfive's process) β
β Client.observe() yields ControlEvent; bridge translates: β
β harmonograf.ControlEvent(STEER, note, suggested_action) β
β β goldfive.ControlMessage(kind=ControlKind.STEER, β
β payload={"note": ...}) β
β await goldfive_channel.send(msg) β
ββββββββββββββββββββββββββββββββββββ¬βββββββββββββββββββββββββββββββββ
β asyncio.Queue.put
ββββββββββββββββββββββββββββββββββββΌβββββββββββββββββββββββββββββββββ
β goldfive.ControlChannel (_inbox) β channel.receive() unblocks β
βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ€
β SequentialExecutor / ParallelDAGExecutor β
β between tasks: drain_controls(channel, ...) β
β mid-task: asyncio.wait({invoke, recv}, FIRST_COMPLETED) β
β dispatch_control(msg) β ControlOutcome(steer_message=msg, ack) β
β channel.ack(outcome.ack) (flows back out) β
ββββββββββββββββββββββββββββββββββββ¬βββββββββββββββββββββββββββββββββ
β outcome.steer_message
ββββββββββββββββββββββββββββββββββββΌβββββββββββββββββββββββββββββββββ
β steerer.observe(msg, session) β
β β DriftEvent(USER_STEER, WARNING, note) β
β DefaultSteerer._handle_drift β
β β emits DriftDetected; severity >= WARNING β planner.refine β
βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ€
β LLMPlanner.refine (USER_STEER, "delete-and-replan") β
β drops pending, preserves completed, returns fresh Plan β
β Steerer._apply_revision + PlanRevised event β
β session.plan = revised; revision_kind=USER_STEER β
βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
Ack side mirrors outbound: _outbox β Client.acks()
β harmonograf β gRPC-Web β UI toast.
Four processes, one proto schema, one logical channel. The UI round-trip for STEER is typically < 100 ms; total latency is dominated by the LLM refine call (seconds), not the transport.
Per-kind: between-task behaviour, mid-task behaviour, effect on the
in-flight task / plan / session. Dispatcher lives in
goldfive/executors/_control.py::dispatch_control.
Pause the outer loop; keep the in-flight task running until it terminates on its own (aborting a partial artifact is usually worse).
- Between tasks: drain sets
paused=True; executor blocks onawait control.receive()until RESUME / CANCEL / STEER / REWIND_TO. - Mid-task: ack only; invoke runs to completion; pause takes effect at the next between-task point.
- Session/plan: unchanged. Drift:
USER_PAUSE/INFOβ visible in the stream, below refine threshold.
Clear the paused flag. Between tasks (paused): exits the blocking
receive. Otherwise: ack, no-op. Session/plan/drift: none.
Abort the run. The only kind that cancels an in-flight invocation.
- Between tasks: raises
_ControlCancelled; executor emitsRunAborted(reason=payload.reason or "cancelled by control"). - Mid-task:
invoke_task.cancel()with a 5 s grace. Adapters that ignore cancellation are orphaned with a warning β we never wedge the run. - Session/plan: running tasks cascade to CANCELLED. Drift:
USER_CANCEL/CRITICAL(audit only β executor short-circuits to abort without routing through refine).
Redirect the run β the canonical live-replan trigger. Payload
{"note": "...", "suggested_action": "..."}. See Β§5 for semantics.
- Between tasks: fed to
steerer.observeβUSER_STEER/WARNINGβplanner.refine. Executor re-readssession.plannext iteration. - Mid-task: adapter task cancelled (5 s grace, same as CANCEL), then the steer applies. Cancelled task β CANCELLED; refined plan typically reintroduces it (or a replacement) as PENDING.
- Session/plan: replaced;
revision_indexincremented. Completed preserved; pending dropped. Drift:USER_STEER/WARNING.
Reset a task and every downstream task to PENDING. Payload
{"task_id": "..."}; unknown id β FAILURE ack.
- Between tasks:
_rewind_planwalks the reachable subgraph viaplan.edges, sets each status toPENDING, drops entries fromsession.completed_resultsandsession.task_progress. - Mid-task: ack only; reset lands at the next checkpoint. To cancel the current task first, send CANCEL β REWIND_TO β RESUME.
- Session/plan: structural reset β no plan revision, no refine. Drift: none.
Emit a run-state snapshot onto the sink stream β explicit "what are you working on?" ping rather than inferring from the last event.
- Any time: emits
DriftDetected(kind=CUSTOM, severity=INFO)with detailstatus_query control_id=... current_task=... completed=N/M pending=t3,t4. - Session/plan/drift: no side effects (CUSTOM/INFO is below refine).
Toggle session._intercept_transfer. Adapters that honour the flag
(ADK) refuse subsequent agent-to-agent transfers and surface them as
drift. Payload {"enabled": true|false}, default true.
- Any time: flag set on the session; next invocation sees it. Mid-task flips apply to the next task.
- Session:
session._intercept_transfer = flag. Drift: none directly; ADK may synthesizeWRONG_AGENT/AGENT_TRANSFER.
Resolve a pending approval waiter in session.pending_approvals.
target_id can be a task_id (Flow A, task-level via
report_awaiting_approval) or a tool_call_id (Flow B, ADK tool
confirmation). Payload {"target_id": "...", "detail": "..."}. See
Β§6 for the dual flow, APPROVAL.md for the design.
- Any time: looks up
pending_approvals[target_id]; stampsmeta["decision"], emitsApprovalGranted/ApprovalRejected, sets theasyncio.Event. Absent target β FAILURE ack. - Session:
pending_approvals[target_id]cleared. Drift: none β approval is a separate stream.
STEER is the only control kind that invokes the planner. On
USER_STEER drift, LLMPlanner.refine follows a
delete-and-replan branch in
goldfive/planner.py::_refine_user_steer:
- Preserve every COMPLETED task. Its output in
session.completed_resultsstays available as context. - Drop every PENDING / RUNNING / BLOCKED / CANCELLED task. The steer implicitly says "what you were going to do is wrong."
- Ask the LLM for a fresh plan with the user's
note/suggested_actionas primary directive, original goals as background, completed outputs as ground truth. - Stamp revision metadata β
revision_index = prev + 1,revision_kind = USER_STEER,revision_severity = WARNING,revision_reason = drift.detail.
Four-task linear plan, mid-draft, user clicks [Steer] with
note="focus on fewer, higher-quality slides":
before:
t1 research COMPLETED results[t1] = "industry landscape..."
t2 draft RUNNING (partial output)
t3 review PENDING
t4 publish PENDING
mid-task STEER cancels t2 (grace-window), observe β USER_STEER drift,
refine runs _refine_user_steer, LLM returns a 2-task plan.
after:
t1 research COMPLETED (preserved)
t5 draft_focused PENDING
t6 polish_and_ship PENDING
# revision_index = 1, revision_kind = USER_STEER
events (in order):
TaskCancelled(t2)
DriftDetected(USER_STEER, WARNING, "focus on fewer...")
PlanRevised(revision_index=1, revision_kind=USER_STEER)
The next executor iteration picks up t5 (no predecessors, PENDING)
and dispatches. From the sink the cancel + revision straddle the
steer; from the user the timeline re-orients.
Refine-as-diff is cheaper but fragile β an LLM authoring a patch has to reason about edge semantics and per-task status, and mistakes compound across revisions. Deleting pending work and re-asking produces clean plans every time and matches user expectation: [Steer] feels like "redirect from here", not "edit the task list." See RATIONALE.md.
Two flows both resolve via APPROVE/REJECT and the same three
proto events (ApprovalRequested, ApprovalGranted,
ApprovalRejected), so a single UI affordance works for either.
The agent calls the 8th reporting tool,
report_awaiting_approval(task_id, prompt, timeout_ms=...). Its
handler:
steerer.mark_task_blocked(task_id, blocker="awaiting_approval").- Registers
session.pending_approvals[task_id] = asyncio.Event()and stashes metadata insession.pending_approvals_meta[task_id]. - Emits
ApprovalRequested{target_id=task_id, kind="task", prompt=...}. awaits the event.- Returns
{"decision": "approve"|"reject"|"timeout"|"unavailable", "detail": ...}to the agent. The wait is finite (timeout_ms, orSteeringConfig.approval_default_timeout_mswhen omitted); with no control channel bound at all the handler skips steps 1-4 and returns"unavailable"immediately β see APPROVAL.md.
The UI renders buttons on ApprovalRequested and sends
ControlMessage(kind=APPROVE|REJECT, payload={"target_id": task_id}).
The agent decides what to do with the returned decision β goldfive
does not auto-transition the task.
An ADK FunctionTool declared with require_confirmation=True.
goldfive's ADK plugin intercepts in before_tool_callback:
- Uses
tool_context.function_call_id(adk-<uuid>) astarget_id. - Registers
pending_approvals[tool_call_id]with metadata{"kind": "tool", "tool_name": ..., "args": ..., "task_id": session.current_task_id}. - Emits
ApprovalRequested{kind="tool", metadata={...}}. awaits the event.- On APPROVE: emits
ApprovalGranted, returnsNoneβ ADK runs the tool. - On REJECT: emits
ApprovalRejected, returns{"skipped": True, "reason": "user_rejected"}β ADK uses that as the tool's response.
The model never sees the interception; from its perspective the tool just took a while. Full ADK plugin reference is in APPROVAL.md.
Both flows need a yes/no, a UI-renderable event, a correlation id,
and a wait-then-resume mechanic. One pair of kinds + one pending map
beats two parallel plumbings. target_id disambiguates: task ids are
planner-authored; tool-call ids are ADK-authored (adk-<uuid>).
If you need a bespoke verb β "checkpoint", "inject a memo", "wait for task X" β two escape hatches.
Unknown kinds still deliver; the default dispatcher acks them as
UNSUPPORTED until the runner-side dispatcher knows them:
from goldfive import ControlChannel, ControlMessage
channel = ControlChannel()
await channel.send(ControlMessage(kind="CHECKPOINT", payload={"label": "pre-refine"}))drain_controls and dispatch_control in
goldfive/executors/_control.py are module-level helpers. Override
the drain loop in your executor subclass and fall back to
dispatch_control for kinds you don't handle:
from goldfive.executors.sequential import SequentialExecutor
from goldfive.executors._control import ControlOutcome, dispatch_control
from goldfive.control import AckResult, ControlAck
class CheckpointingExecutor(SequentialExecutor):
async def _handle_custom(self, msg, *, session, **kw):
kind = str(getattr(msg.kind, "value", msg.kind)).upper()
if kind == "CHECKPOINT":
label = str(msg.payload.get("label", ""))
session.agent_notes.setdefault("_checkpoints", []).append(label)
return ControlOutcome(
ack=ControlAck(
control_id=msg.id,
result=AckResult.SUCCESS,
detail=f"checkpointed at {label}",
),
)
return await dispatch_control(msg, session=session, **kw)No registry yet β custom dispatchers usually also want custom
executor state (the _checkpoints list above), so the override point
is naturally the executor. File an issue if you write more than one.
ControlChannel is asyncio.Queue-backed. Tests instantiate one
directly, drive it with await channel.send(...), and assert on the
acks iterator or on events captured by an InMemorySink. Patterns:
tests/test_control_primitive.py, tests/test_executor_control.py,
tests/test_live_steering_e2e.py. A runnable demo covering PAUSE /
STEER / CANCEL against an offline canned-LLM planner lives at
examples/live_steering.py.
A STEER control message is the user-initiated entry into a broader
intervention story. Goldfive-internal drift detectors (loop, refusal,
goal drift, ...) enter the same pipeline via synthesized drifts, and
both paths route through DriftObserver.handle_drift (the steerer's
drift component) which maps
(drift_kind, severity, occurrence_count) to one of six levels:
| Level | Name | What happens |
|---|---|---|
| 0 | OBSERVE |
Emit DriftDetected; no further action. |
| 1 | ABSORB |
Call planner.refine; install revised plan; continue. This is where USER_STEER lands. |
| 2 | NUDGE |
Queue a corrective user message on session.pending_nudges for the overlay loop to replay at the next invocation boundary. On the default table for LOOPING_REASONING CRITICAL-first (goldfive#204), GOAL_DRIFT WARNING / CRITICAL-first (goldfive#324), and TASK_TIMEOUT WARNING (goldfive#487). The enqueue is gated on is_active_steering() (goldfive#475): under observation_only=True the would-be nudge is logged and a PolicyApplied gate event is stamped instead. |
| 3 | CANCEL_REINVOKE |
Refine; install revised plan; dispatch a GOLDFIVE_STEER ControlMessage on the bound channel so the executor's invoke loop cancels the in-flight invocation and restarts with a [GOLDFIVE STEERING CONTROL β¦] framed corrective. |
| 4 | PAUSE_ESCALATE |
Emit HUMAN_INTERVENTION_REQUIRED; dispatch a GOLDFIVE_PAUSE_ESCALATE ControlMessage on the bound channel so the executor's pre-task loop blocks for user input. (Phase 2 of #246 replaced the deleted session.paused_for_human_intervention flag with this channel-routed signal.) |
| 5 | TERMINATE |
Pause-with-deadline: same dispatch as Level 4 but the payload always carries deadline_s (SteeringConfig.pause_escalate_deadline_s, or 600 s when unset). On expiry the executor cancels non-terminal tasks and emits RunAborted with the escalation lineage. Reached on repeat Level-4 that didn't resolve. |
The per-(drift_kind, severity) mapping lives in
DriftObserver._LADDER (see goldfive/drift_observer.py; the
steerer's drift component). Replace the component or subclass
DriftObserver and override _ladder_level_for to tune the table.
USER_STEER specifically maps to Level 1 (ABSORB) at WARNING (the
default severity) β refine runs synchronously, the revised plan
installs, and the overlay loop restarts with the steer body as the
new user input (framed via _compose_steer_restart_message, see
goldfive#152). USER_CANCEL is special-cased to short-circuit the
ladder into an unconditional RunAborted.
See DRIFT.md Β§"Intervention ladder (Levels 0-5)"
for the full per-drift-kind mapping and the corresponding code
path in goldfive/drift_observer.py.
- Not a scheduler. Send-order dispatch; no priority, no deadline ordering, no cancelling pending messages. PAUSE then CANCEL pauses first, then cancels.
- Not a transport. Process-local (
asyncio.Queue). Cross-process transport is the bridge's problem (Β§3); the contract is theControlMessagedataclass. - Not a persistence layer. Raw control messages aren't logged to
sinks; their effects are (DriftDetected, TaskCancelled,
PlanRevised, ApprovalGranted). For an audit trail of raw messages,
emit a
CUSTOM-kind drift from a custom dispatcher. - Not required.
Runner(control=None)β the default β runs end-to-end with zero live-steering surface.
- VOCABULARY.md β type-system reference, the
ControlKindΓDriftKindbridge tables, worked STEER β USER_STEER example. - RATIONALE.md β why one channel, why STEER deletes, why approval rides the control path.
- APPROVAL.md β approval design + ADK plugin API.
- DRIFT.md, STATE-MACHINE.md, PROTOCOLS.md β related design docs.
- ../reference/api.md β control types in the public API.
- ../guides/getting-started.md, ../guides/observability-with-harmonograf.md β hands-on walkthroughs.