Hand-maintained reference for goldfive's public API. When this doc disagrees with the code, trust the code and file a patch to fix the doc.
Related: PROTOCOLS.md, ARCHITECTURE.md, DRIFT.md.
goldfive's session is an actor; the ControlChannel is its only
mailbox; state mutations to session.plan and task status happen
only inside the channel processor. Every drift verdict carries
observed_revision_index so stale verdicts can be rejected at
dispatch. The full design — motivation (a pair of false-positive
cascade traces), invariants, and migration story — is in
../design/CONTROL-CHANNEL.md. Read it
before adding a new control kind, a new drift judge, or any code
that mutates session.plan.
Everything documented here is re-exported from goldfive.__init__
(see goldfive.__all__ for the canonical list):
from goldfive import (
Runner, wrap, run, quickstart,
# types
Goal, Plan, Task, TaskEdge, TaskStatus, DriftKind, DriftSeverity,
DriftEvent, Session,
# protocols
GoalDeriver, Planner, Executor, Steerer, AgentAdapter, EventSink,
# results
InvocationResult, ExecutionOutcome,
# reporting
ReportingToolSpec, BUILTIN_REPORTING_TOOLS,
# live steering
ControlChannel, ControlMessage, ControlAck, ControlKind, AckResult,
# default implementations re-exported at the package root
CallableAdapter,
SequentialExecutor, ParallelDAGExecutor,
PassthroughPlanner, StaticPlanner, LLMPlanner,
PassthroughGoalDeriver, LiteralGoalDeriver, LLMGoalDeriver,
DefaultSteerer,
InMemorySink, LoggingSink,
JSONLPersistenceSink, SQLitePersistenceSink, GRPCSink,
# drift helpers
classify_tool_error, classify_refusal, classify_stop_reason,
)Subpackages for framework adapters gated behind optional extras:
from goldfive.adapters.callable import CallableAdapter
from goldfive.adapters.adk import ADKAdapter # extra: adk
from goldfive.adapters.claude import ClaudeAgentSDKAdapter # extra: claude
from goldfive.adapters.auto import auto_adapterLoggingSink, JSONLPersistenceSink, SQLitePersistenceSink, and
GRPCSink require the proto extra at runtime (they import
google.protobuf eagerly; GRPCSink also needs grpcio). They
appear in goldfive.__all__ unconditionally; when the extra is
missing the module attribute resolves to None at import time,
surfacing the missing dependency at construction rather than import.
One-line convenience wrappers over Runner. wrap returns a
Runner pre-wired with sensible defaults and an auto-detected
AgentAdapter; run is wrap(...).run(user_input) in a single
call.
def wrap(
agent: Any,
*,
planner: Planner | None = None,
goal_deriver: GoalDeriver | None = None,
executor: Executor | None = None,
steerer: Steerer | None = None,
sinks: list[EventSink] | None = None,
control: ControlChannel | None = None,
call_llm: Callable[[str, str, str], Awaitable[str]] | None = None,
model: str | None = None,
max_task_invocations: int | None = None,
plugins: list[Any] | None = None,
runtime: RuntimeConfig | None = None,
dynamic_instruction: bool = True,
drift_self_reporting: bool | list[str] = False,
llm_detector: Any = None,
judge_call_llm_builder: Any = None,
judges: list[Judge] | None = None,
disable_judges: Iterable[BuiltinJudge | str] | None = None,
) -> Runner: ...
async def run(
agent: Any,
user_input: str | list[Goal],
*,
context: Mapping[str, Any] | None = None,
**wrap_kwargs: Any,
) -> ExecutionOutcome: ...agent can be any of:
- an object implementing
goldfive.AgentAdapter(used verbatim), - a
google.adk.agents.BaseAgentor ADKRunner(requiresgoldfive[adk]), - a zero-arg callable returning
claude_agent_sdk.ClaudeSDKClient(requiresgoldfive[claude]), - an async
(task, session, tools) -> InvocationResultcallable.
Adapter dispatch favours ADK over the async-callable path so ADK
agents are not misrouted to CallableAdapter. Unknown shapes raise
TypeError with a pointer to the supported options.
Default executor is SequentialExecutor(overlay_mode=True, max_task_invocations=max_task_invocations); callers who supply their
own executor= retain full control of the execution model.
When no call_llm is supplied and the agent does not expose an LLM
surface wrap can detect (currently only ADK), wrap falls back to
PassthroughPlanner + LiteralGoalDeriver and emits a DEBUG
log line on the goldfive.wrap logger.
plugins= is forwarded to ADKAdapter(plugins=...) and installed on
the one runner. ADK propagates the plugin manager into any
AgentTool-spawned sub-Runner so delegation inherits the same plugin
surface. Duplicate plugin instances (same plugin.name) are
silently deduped (#166/#169).
A Judge is the pluggable verdict-producing extension point (#437):
an object with a stable name: str and an async
evaluate(JudgeContext) -> JudgeVerdict. The steerer runs every
installed judge at each observation point and emits a
JudgementEmitted envelope per populated verdict. Judges produce one
of four verdict flavours: drift (drift_kind + severity),
rubric (rubric_score + per-dimension scores), boolean
(pass/fail), numeric (a named metric). JudgeVerdict.drift_kind /
severity accept the typed DriftKind / DriftSeverity enums.
wrap installs goldfive.builtin_judges.default_judges() — the
built-in detector set (reasoning_drift, looping_reasoning,
goal_drift, refusal, tool_error, stop_reason, looping_tool) —
unless the caller controls the set explicitly:
-
judges=[...]— the explicit list. Replaces the default set entirely. Passjudges=[]to disable theJudgementEmittedsurface. Mix built-in factories with custom judges:runner = goldfive.wrap( agent, judges=[ goldfive.builtin_judges.reasoning_drift(), MyRubricJudge(rubric="..."), ], )
-
disable_judges=[...]— keep the default set minus the named built-ins. Members aregoldfive.builtin_judges.BuiltinJudgeenum values (aStrEnum; legacy wire-name strings also accepted). The typed, surgical opt-out — no need to re-list the rest:from goldfive.builtin_judges import BuiltinJudge # Full default set minus the tool-error judge. runner = goldfive.wrap(agent, disable_judges=[BuiltinJudge.TOOL_ERROR])
Passing both
judges=anddisable_judges=raisesTypeError— an explicitjudges=list already specifies the exact set.
Drift-flavoured verdicts (from built-in or custom judges) also fire
the legacy DriftDetected envelope, so existing sinks see no
behavioural change.
When the wrap target is an ADK BaseAgent, the returned object is a
GoldfiveADKAgent — a BaseAgent subclass that also exposes the
Runner surface, so the same call site works programmatically and as
the root_agent of an adk web app. GoldfiveADKAgent pins the
outer adk-web session id onto the inner ADKAdapter so all three
session layers align (#161/#164).
Direct auto_adapter access is available for callers who want the
dispatch logic without the Runner defaults:
from goldfive.adapters.auto import auto_adapter
adapter: AgentAdapter = auto_adapter(agent)One-call Runner factory for the common case. Complements wrap —
quickstart always uses a static one-task-per-goal plan rather than
an LLM-driven planner.
def quickstart(
agent: Any,
goals: str | Goal | list[str | Goal],
*,
planner: Planner | None = None,
sinks: list[EventSink] | None = None,
) -> Runner: ...agent— either an existingAgentAdapter(used verbatim) or aCallableAdapter-compatible async callable (wrapped).goals— a single string, a singleGoal, or a list of either. Each entry becomes one task in the default plan.planner/sinks— optional overrides. Defaults to aStaticPlannerwhose plan has one task per goal and[InMemorySink()]respectively.
The single entry point.
class Runner:
def __init__(
self,
*,
agent: AgentAdapter,
planner: Planner,
executor: Executor,
goal_deriver: Optional[GoalDeriver] = None,
steerer: Optional[Steerer] = None,
sinks: Optional[list[EventSink]] = None,
control: Optional[ControlChannel] = None,
max_task_invocations: Optional[int] = None,
) -> None: ...
async def run(
self,
user_input: str | list[Goal],
*,
context: Optional[Mapping[str, Any]] = None,
) -> ExecutionOutcome: ...
async def resume(
self,
persistence_path: str,
) -> ExecutionOutcome: ... # best-effort JSONL replay
async def close(self) -> None: ... # close every sink, then run hooks
# Post-construction extension API. See "Extension API" below.
def add_sink(self, sink: EventSink) -> None: ...
def add_close_hook(
self, hook: Callable[[], Awaitable[None]]
) -> None: ...
@property
def control(self) -> ControlChannel | None: ...
@control.setter
def control(self, value: ControlChannel) -> None: ...Runner.resume(persistence_path) loads a JSONL log written by
JSONLPersistenceSink, replays it through
goldfive.sinks.reconstruct_session, and returns an
ExecutionOutcome whose session reflects the latest state seen in
the log. v0.1 does not continue execution from the cursor — callers
who want live continuation should build a fresh Runner from the
recovered goals. Tracked in issue #15.
| Parameter | Default | Notes |
|---|---|---|
agent |
required | Any AgentAdapter. |
planner |
required | Any Planner. |
executor |
required | Any Executor. Typically SequentialExecutor() or ParallelDAGExecutor(). |
goal_deriver |
PassthroughGoalDeriver("run") |
Optional. When None, the Runner substitutes a passthrough deriver that emits a single Goal(id="g1", summary="run"). |
steerer |
DefaultSteerer() |
Optional. |
sinks |
[] |
Optional. Recommended: at least InMemorySink in tests and JSONLPersistenceSink in prod. |
control |
None |
Optional ControlChannel for live pause / cancel / steer / rewind / approve / reject. When None, the run has no live-steering surface. May also be attached post-construction via the control setter; see "Extension API" below. See ../design/CONTROL.md. |
max_task_invocations |
None (unbounded) |
Optional cap on adapter invocations per run, stamped onto the planner context so executors that honour it can cap refine / task-invocation loops. Accepts the deprecated max_plan_reinvocations kwarg for one release with a DeprecationWarning. |
Three post-construction hooks let external integrations attach additional behaviour without subclassing or attribute mutation:
add_sink(sink)— append anEventSinkafter construction. Takes effect for events emitted by subsequentrun()calls; in-flight runs keep the sink list they started with.add_close_hook(hook)— register an async callable invoked byclose()after sinks are closed. Hooks fire in registration order; an exception in one hook is logged and does not prevent the rest from running.close()is idempotent — a second call is a no-op and hooks fire exactly once.controlsetter — attach aControlChannelafter construction. Idempotent on identity (is) re-attach; raisesRuntimeErrorif a different channel is already attached. The constructor kwargcontrol=...is unchanged; the setter is the post-construction path.
GoldfiveADKAgent (returned by goldfive.wrap(adk_agent)) mirrors
the same contract and delegates to the inner Runner, so callers
can use the extension API uniformly whether they hold a plain
Runner or an ADK-wrapped one.
class TaskStatus(StrEnum):
PENDING
RUNNING
COMPLETED
FAILED
CANCELLED
BLOCKED
NOT_NEEDED # terminal; overlay-mode sweep at invocation end (#141/#163)TERMINAL_TASK_STATUSES = {COMPLETED, FAILED, CANCELLED, NOT_NEEDED}.
class DriftSeverity(StrEnum):
INFO
WARNING
CRITICALFull taxonomy. Proto numbers are the authoritative wire values from
proto/goldfive/v1/types.proto::DriftKind. Python StrEnum values
use snake_case for forward compatibility with sinks.
| Proto # | Name | Python value | Meaning |
|---|---|---|---|
| 1 | TOOL_ERROR | "tool_error" |
A tool invocation raised or returned a structured error. |
| 2 | AGENT_REFUSAL | "agent_refusal" |
Agent declined to perform; graduated severity via classify_refusal. |
| 3 | NEW_WORK_DISCOVERED | "new_work_discovered" |
report_new_work_discovered called; refine expected. |
| 4 | PLAN_DIVERGENCE | "plan_divergence" |
report_plan_divergence; cross-layer AgentTool call from GoldfivePlanner. |
| 5 | USER_STEER | "user_steer" |
ControlKind.STEER arrived; cascade-cancel + refine. |
| 6 | USER_CANCEL | "user_cancel" |
ControlKind.CANCEL arrived. |
| 7 | TASK_FAILED_RECOVERABLE | "task_failed_recoverable" |
report_task_failed(recoverable=True); WARNING. |
| 8 | TASK_FAILED_FATAL | "task_failed_fatal" |
report_task_failed(recoverable=False); CRITICAL. |
| 9 | CONTEXT_PRESSURE | "context_pressure" |
Stop-reason implied context / token pressure. |
| 10 | BLOCKED | "blocked" |
report_task_blocked; awaits external input. |
| 11 | WRONG_AGENT | "wrong_agent" |
Agent-transfer landed at the wrong target. |
| 12 | AGENT_TRANSFER | "agent_transfer" |
Transfer observed; informational. |
| 13 | MODEL_REFUSAL | "model_refusal" |
Provider-level refusal. |
| 14 | STOPPED_EARLY | "stopped_early" |
Response truncated before the task completed. |
| 15 | TOO_MANY_STEPS | "too_many_steps" |
Per-task / per-lineage step cap exceeded. |
| 16 | GOAL_UNREACHABLE | "goal_unreachable" |
Planner concluded no plan can satisfy the goal. |
| 17 | TASK_TIMEOUT | "task_timeout" |
Stall watchdog (SteeringConfig.stall_watchdog_enabled, default OFF): no observed activity for stall_timeout_s. |
| 18 | REPEATED_FAILURE | "repeated_failure" |
N consecutive refine failures for the same (kind, task) pair. |
| 19 | UNEXPECTED_OUTPUT | "unexpected_output" |
Output shape violates declared schema. |
| 20 | SCHEMA_VIOLATION | "schema_violation" |
Hard JSON / schema parse failure. |
| 21 | HALLUCINATION_SUSPECTED | "hallucination_suspected" |
Content inconsistent with the session's facts. |
| 22 | SAFETY_CONCERN | "safety_concern" |
Policy / safety signal. |
| 23 | RESOURCE_EXHAUSTED | "resource_exhausted" |
Rate limits, quota, etc. |
| 24 | AMBIGUOUS_INTENT | "ambiguous_intent" |
Signals the planner needs clarification. |
| 25 | CUSTOM | "custom" |
Escape hatch paired with DriftDetected.detail. |
| 26 | LOOPING_TOOL_CALL | "looping_tool_call" |
Legacy reporting-tool loop guard kind; retired in goldfive#206. The enum value is preserved for proto stability; ToolLoopTracker emits LOOPING_REASONING instead. |
| 27 | LOOPING_REASONING | "looping_reasoning" |
Reasoning-content loop (hash/embedding) or tool-loop detector (#181). |
| 28 | (reserved) | (reserved) | Was CONFUSION (regex-based uncertainty-marker count); retired. The reasoning judge (iter-10 three-state classifier) covers the same semantic ground without the regex-NL anti-pattern. |
| 29 | OFF_TOPIC | "off_topic" |
Reasoning topic distant from task description (embedding). |
| 30 | INTENT_DIVERGENCE | "intent_divergence" |
Reasoning mentions a non-session goal; graduated severity. |
| 31 | UNCERTAIN_PROGRESS | "uncertain_progress" |
Opt-in reflective check: yes-but-low-confidence. |
| 32 | SELF_REPORTED_STUCK | "self_reported_stuck" |
Opt-in reflective check: agent says no progress. |
| 33 | REASONING_CLUSTER_TIGHTENING | "reasoning_cluster_tightening" |
Embedding-only early-warning below the LOOPING_REASONING cliff. |
| 34 | CONFABULATION_RISK | "confabulation_risk" |
Task implies external data but no tool was called; hallucinated function_call name (from GoldfivePlanner). |
| 35 | RUNAWAY_DELEGATION | "runaway_delegation" |
ADKAdapter(agent_tool_cap=N) exceeded; CRITICAL. |
| 36 | REFINE_VALIDATION_FAILED | "refine_validation_failed" |
LLMPlanner.refine exhausted retries; CRITICAL terminal signal. |
| 37 | HUMAN_INTERVENTION_REQUIRED | "human_intervention_required" |
Ladder Level 4 escalation; CRITICAL. |
| 38 | GOAL_DRIFT | "goal_drift" |
Periodic trajectory-level goal-alignment check; CRITICAL. |
DriftKind.USER_PAUSE also exists on the Python side (no proto
member) for in-process PAUSE bookkeeping.
See DRIFT.md for severity bands, classifier pipelines, and the intervention ladder's level-mapping table.
@dataclass
class Task:
id: str
title: str
description: str = ""
assignee_agent_id: str = ""
status: TaskStatus = TaskStatus.PENDING
predicted_start_ms: int = 0
predicted_duration_ms: int = 0
bound_span_id: str = ""@dataclass
class TaskEdge:
from_task_id: str
to_task_id: str@dataclass
class Plan:
id: str
run_id: str
goal_ids: list[str]
tasks: list[Task]
edges: list[TaskEdge]
summary: str = ""
revision_reason: str = ""
revision_kind: str = "" # DriftKind value or ""
revision_severity: str = "" # DriftSeverity value or ""
revision_index: int = 0
def topological_stages(self) -> list[list[Task]]:
"""Kahn's algorithm — returns tasks grouped by DAG depth."""@dataclass
class Goal:
id: str
summary: str
success_predicate: Optional[Callable[[Session], bool]] = None
metadata: dict[str, str] = field(default_factory=dict)@dataclass
class DriftEvent:
kind: DriftKind
severity: DriftSeverity
detail: str = ""
current_task_id: str = ""
current_agent_id: str = ""
raw: Any = NoneAbbreviated shape (see goldfive.types.Session for the full
dataclass including reflective-check counters and ladder-handoff
slots):
@dataclass
class Session:
run_id: str
conversation_id: str = ""
goals: list[Goal] = field(default_factory=list)
plan: Plan | None = None
current_task_id: str = ""
# Agent-authored SUMMARY per task (lossy status-line metadata).
completed_results: dict[str, str] = field(default_factory=dict)
# Agent's COMPLETE ACTUAL OUTPUT per task — the canonical, gradeable
# artifact. Recorded independent of self-reporting. Prefer this over
# ``completed_results`` for grading. See ../guides/evaluation.md.
completed_outputs: dict[str, str] = field(default_factory=dict)
task_progress: dict[str, float] = field(default_factory=dict)
agent_notes: dict[str, str] = field(default_factory=dict)
divergence_flag: bool = False
started_at_ms: int = 0
# Reasoning-drift pipeline (goldfive#96)
reasoning_history: list[str] = field(default_factory=list)
reasoning_history_max: int = 20
# Intervention-ladder handoffs (goldfive#142)
pending_nudges: list[str] = field(default_factory=list)
# Phase 2 of #246 deleted ``paused_for_human_intervention`` and
# ``pending_corrective_message``; Levels 3 (CANCEL_REINVOKE) and
# 4 (PAUSE_ESCALATE) now route through the bound ControlChannel
# via GOLDFIVE_STEER / GOLDFIVE_PAUSE_ESCALATE ControlMessages.
# Goldfive-orchestration session state (goldfive#152). Owned key
# names live in ``goldfive.orchestration_state``; see below.
state: dict[str, Any] = field(default_factory=dict)
def next_sequence(self) -> int:
"""Monotonic event sequence counter."""
@property
def id(self) -> str:
"""Alias for ``run_id``; used as ``Event.session_id`` (goldfive#155)."""goldfive.orchestration_state owns these keys under goldfive.*:
| Key | Written by | Read by |
|---|---|---|
goldfive.current_plan_id |
plan-submitted / plan-revised paths | planners, sinks |
goldfive.current_task_id |
PlanReconciler on RUNNING |
GoldfivePlanner, sinks |
goldfive.current_task_title |
same | same |
goldfive.goals_summary |
Runner (on goals change / USER_STEER) | GoldfivePlanner |
goldfive.active_steer.body |
DefaultSteerer on USER_STEER |
GoldfivePlanner |
goldfive.active_steer.at_turn |
same | refine / drift |
goldfive.active_steer.author |
same | sinks |
goldfive.processed_steer_ids |
DefaultSteerer.observe dedupe |
self |
goldfive.cancelled_function_call_ids |
adapter _heal_pending_tool_calls |
GoldfivePlanner response filter |
_GoldfiveADKPlugin.before_run_callback bridges a subset of these
from goldfive.Session.state onto the live ADK session.state so
GoldfivePlanner sees them on its request-side read
(goldfive#170/#173).
Returned by AgentAdapter.invoke().
@dataclass
class InvocationResult:
task_id: str
text: str = "" # the LAST assistant text turn (legacy)
stop_reason: str = ""
error: Optional[Exception] = None
raw: Any = None
text_turns: list[str] = field(default_factory=list) # every text turn, in order
full_text: str = "" # text_turns joined by TURN_SEPARATOR; falls back to texttext is the final assistant turn only — kept for backward
compatibility. For grading, read full_text (or text_turns): an
agent often emits its substantive answer in one turn and a terse
wrap-up in a later turn, and text keeps only the latter. See
../guides/evaluation.md.
Returned by Executor.run() and Runner.run().
@dataclass
class ExecutionOutcome:
success: bool
session: Session
reason: str = ""All are @runtime_checkable. Every method is async unless explicitly
marked otherwise below. Full contracts in
PROTOCOLS.md.
| Protocol | Methods |
|---|---|
GoalDeriver |
derive(user_input, *, context=None) -> list[Goal] |
Planner |
generate(*, goals, available_agents, context=None) -> Plan | None · refine(*, plan, drift, goals, observed_actions=None, available_agents=None) -> Plan | None |
Steerer |
observe(event, session) · transition(task_id, to, *, detail="", session) · detect_drift(event, session) -> DriftEvent | None (sync) · bind(*, sinks, planner) (sync) |
AgentAdapter |
register_reporting_tools(tools) · invoke(task, session) -> InvocationResult · emit_reasoning(text, *, task=None, session, provider="", call_id="") · property available_agents: list[str] (sync) |
Executor |
run(*, plan, session, adapter, steerer, planner, sinks, control=None, user_input="") — overlay-mode executors honour user_input; legacy per-task executors ignore it. |
EventSink |
emit(event_pb) · close() |
Overlay-specific adapter methods are duck-typed, not in the
AgentAdapter protocol: invoke_passthrough(user_message, *, session, reconciler=None, ctx=None) and invoke_follow_up(task, session) are
defined on ADKAdapter. Custom adapters that want to participate in
the overlay execution path implement them; callers look them up via
getattr and fall back to invoke when absent. Similarly,
available_agents_tree (goldfive#151) is a duck-typed property
shipped on ADKAdapter / CallableAdapter / ClaudeAgentSDKAdapter
but not part of the Protocol contract.
class PassthroughGoalDeriver(GoalDeriver):
def __init__(self, goals: str | list[str] | list[Goal]) -> None: ...
# ``derive(user_input, ...)`` ignores ``user_input`` and returns
# the pre-configured goals verbatim.
class LiteralGoalDeriver(GoalDeriver):
def __init__(self) -> None: ...
# ``derive(user_input, ...)`` wraps a non-empty string as a single
# ``Goal(id="g1", summary=user_input)``.
class LLMGoalDeriver(GoalDeriver):
def __init__(
self,
call_llm: Callable[[str, str, str], Awaitable[str]],
model: str = "",
*,
system_prompt: Optional[str] = None,
) -> None: ...class PassthroughPlanner(Planner):
def __init__(self) -> None: ...
# generate() and refine() always return None.
class StaticPlanner(Planner):
def __init__(self, plan: Plan) -> None: ...
# generate() returns a fresh copy of ``plan`` with run_id and
# goal_ids rewritten to match the current session. refine()
# always returns None.
class LLMPlanner(Planner):
DEFAULT_MAX_REFINE_ATTEMPTS: int = 2
def __init__(
self,
*,
call_llm: Callable[[str, str, str], Awaitable[str]],
model: str = "",
system_prompt: str | None = None,
refine_system_prompt: str | None = None,
user_steer_system_prompt: str | None = None,
looping_tool_call_system_prompt: str | None = None,
plan_divergence_system_prompt: str | None = None,
max_refine_attempts: int | None = None,
) -> None: ...
async def generate(
self,
*,
goals: list[Goal],
available_agents: list[str] | list[dict[str, Any]] | None,
context: Mapping[str, Any] | None = None,
) -> Plan | None: ...
async def refine(
self,
*,
plan: Plan,
drift: DriftEvent,
goals: list[Goal],
observed_actions: list[ObservedAction] | None = None,
available_agents: list[str] | list[dict[str, Any]] | None = None,
) -> Plan | None: ...available_agents may be a plain list[str] (legacy callers) or the
structured walker produced by
ADKAdapter.available_agents_tree (goldfive#151). When the tree form
is supplied the prompt renders an AGENT TREE section and the
validator rejects any task whose assignee_agent_id is not in the
registry, feeding the validator message back into a
retry-with-correction loop. An empty / None registry skips the
assignee check for back-compat.
refine behaviour by drift kind:
drift.kind |
Prompt used | Uses observed_actions? |
Uses available_agents? |
|---|---|---|---|
USER_STEER |
user-steer system prompt | no | yes |
LOOPING_TOOL_CALL / LOOPING_REASONING |
looping-tool-call prompt | no | yes |
PLAN_DIVERGENCE with observed_actions |
divergence / reconciler prompt | yes — ABSORB or {"reject": true, ...} |
yes |
REFINE_VALIDATION_FAILED |
— | — | — (returns None; terminal) |
| everything else | generic refine prompt | no | yes |
Goal-aware refine (#154): goals are included in the divergence
prompt; USER_STEER-sourced goals render with [STICKY], and the
validator rejects revisions that silently drop them.
class DefaultSteerer(Steerer):
# Implements the full state machine + drift classifier from
# DRIFT.md and STATE-MACHINE.md plus the six-level intervention
# ladder. No required constructor args.In addition to the Steerer protocol methods, DefaultSteerer
exposes mark_task_running, mark_task_progress,
mark_task_completed, mark_task_failed, mark_task_blocked,
mark_task_cancelled, report_new_work_discovered, and
report_plan_divergence for the canonical reporting-tool handlers to
call into.
STEER idempotency (goldfive#171): observe(event, session) dedupes
ControlMessages of kind STEER by source annotation id. The
dedupe key is taken from the ControlMessage.payload["annotation_id"]
when the bridge supplied one; otherwise the ControlMessage.id is
used as the fallback. Processed ids are stored in
session.state[goldfive.processed_steer_ids] with FIFO eviction at
DefaultSteerer.PROCESSED_STEER_IDS_CAP. Content-based drifts
(LOOPING_REASONING, tool errors, etc.) are not deduped — they
are heuristic signals, not user actions.
Intervention ladder (goldfive#142):
| Level | Name | Action |
|---|---|---|
| 0 | OBSERVE | Emit DriftDetected; no further action. |
| 1 | ABSORB | Call planner.refine; continue. |
| 2 | NUDGE | Queue a soft follow-up on session.pending_nudges; overlay loop picks it up at the next invocation boundary. |
| 3 | CANCEL_REINVOKE | Refine; install revised plan; dispatch a GOLDFIVE_STEER ControlMessage on the bound channel so the executor cancels the in-flight invoke and restarts with a [GOLDFIVE STEERING CONTROL …] framed corrective. (Phase 2 of #246.) |
| 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 operator intervention. (Phase 2 of #246.) |
| 5 | TERMINATE | Pause-with-deadline: same dispatch as Level 4 but always deadline-bounded (SteeringConfig.pause_escalate_deadline_s, or 600 s when unset); on expiry the executor cancels non-terminal tasks and emits RunAborted. |
Mapping from (drift_kind, severity, occurrence_count) to level lives
in DefaultSteerer._ladder_level_for.
ADK BasePlanner subclass auto-attached by goldfive.wrap to every
LlmAgent in the tree (goldfive#153/#156). Two jobs:
- Request side —
build_planning_instructionreturns a tree-agnostic[GOLDFIVE ORCHESTRATION CONTEXT]block assembled fromsession.state[goldfive.*]keys. Request-side injection is performed by_GoldfiveADKPlugin.before_model_callback(workaround for ADK'sisinstance(planner, PlanReActPlanner)gate in_nl_planning.py). - Response side —
process_planning_responsefilters response parts:- strips
function_callparts whose id is insession.state[goldfive.cancelled_function_call_ids] - classifies each remaining
function_callvia a three-stage gate (goldfive#178/#184): own-tool → skip; cross-layer agent name in the tree registry →PLAN_DIVERGENCE(WARNING); hallucinated name →CONFABULATION_RISK(WARNING). Calls are never blocked.
- strips
class GoldfivePlanner(BasePlanner):
def __init__(
self,
*,
user_planner: BasePlanner | None = None,
agent_registry: Iterable[str] | None = None,
steerer: Any = None,
session: Any = None,
) -> None: ...
def bind(
self,
*,
agent_registry: Iterable[str] | None = None,
steerer: Any = None,
session: Any = None,
) -> None: ...
def build_planning_instruction(
self, readonly_context, llm_request
) -> str | None: ...
def process_planning_response(
self, callback_context, response_parts
) -> list[Part] | None: ...Composes with a user-supplied BasePlanner via user_planner=: the
user's build_planning_instruction is called first and prepended
ahead of goldfive's block; on the response side goldfive's filters
run first and the cleaned parts flow through the user planner's
process_planning_response.
Tool-call loop detector (goldfive#181/#186) plumbed on every
after_tool_callback dispatch in _GoldfiveADKPlugin. Per-
(invocation_id, agent_name) ring buffer; emits
DriftEvent(kind=LOOPING_REASONING, ...) on three patterns.
class ToolLoopTracker:
def __init__(
self,
*,
window: int = 10, # DEFAULT_WINDOW (bumped in #204 to fit meta-CRITICAL@10)
exact_threshold: int = 3, # DEFAULT_EXACT_THRESHOLD (overrides work-WARNING exact)
name_threshold: int = 5, # DEFAULT_NAME_THRESHOLD (overrides work-WARNING name)
alternating_threshold: int = 5, # DEFAULT_ALTERNATING_THRESHOLD
name_axis_max_severity: str = "info", # DEFAULT_NAME_AXIS_MAX_SEVERITY (uncorroborated name-axis cap)
) -> None: ...
def observe_tool_call(
self,
*,
invocation_id: str,
agent_name: str,
tool_name: str,
args: Any,
task_id: str = "",
) -> list[DriftEvent]: ...
def on_task_progress(
self, *, invocation_id: str, agent_name: str
) -> None: ...
def clear(self) -> None: ...
def buffer_size(self, *, invocation_id: str, agent_name: str) -> int: ...
def args_hash(args: Any) -> str: ... # 8-char md5 hex of sorted-keys JSON
def load_thresholds_from_env() -> dict[str, int | str]: ...Detection modes:
| Mode | Pattern | Default | Severity |
|---|---|---|---|
| Exact | same (tool_name, args_hash) ≥ threshold in last window |
3 / 7 | WARNING |
| Name | same tool_name ≥ threshold in last window, no task progress |
5 / 7 | tier severity when the window also holds an exact repeat; capped at name_axis_max_severity (default "info") otherwise |
| Alternating | A,B,A,B,A pattern in last alternating_threshold |
5 | INFO |
Progress-reporting tools (report_task_*) call
on_task_progress(...) which clears the per-(invocation, agent)
window, so legitimate scripted sequences aren't flagged.
Env-var overrides:
GOLDFIVE_TOOL_LOOP_WINDOW,
GOLDFIVE_TOOL_LOOP_EXACT_THRESHOLD,
GOLDFIVE_TOOL_LOOP_NAME_THRESHOLD,
GOLDFIVE_TOOL_LOOP_ALTERNATING_THRESHOLD,
GOLDFIVE_TOOL_LOOP_NAME_AXIS_MAX_SEVERITY.
Follow-ups tracked: #179 (umbrella), #182 (args-quality), #183 (silent-success), #185 (wrong-tool).
class SequentialExecutor(Executor):
def __init__(
self,
*,
max_task_invocations: int | None = None, # None = unbounded
max_retries_per_task_lineage: int = 3,
fail_fast: bool = True,
overlay_mode: bool = False, # goldfive#141
) -> None: ...
class ParallelDAGExecutor(Executor):
def __init__(
self,
max_concurrency: int = 0, # 0 = unbounded fan-out
drift_policy: Literal["cancel_stage", "finish_stage"] = "finish_stage",
max_task_invocations: int | None = None, # None = unbounded
) -> None: ...overlay_mode=True (set by goldfive.wrap by default) swaps the
executor's per-task loop for a single
adapter.invoke_passthrough(user_input) invocation. Observation
happens via the plugin callback surface and PlanReconciler maps
observed agent turns to plan-task transitions. STEER control
messages cancel the in-flight invocation and restart invoke_passthrough
with the composed steer-restart message as the new user input. At
invocation end any task still PENDING lands in
TaskStatus.NOT_NEEDED (no soft follow-up, goldfive#163).
# Always available
class CallableAdapter(AgentAdapter):
def __init__(
self,
agent: Callable[
[Task, Session, list[ReportingToolSpec]],
Awaitable[InvocationResult],
],
*,
available_agents: Optional[list[str]] = None,
) -> None: ...
# Requires `goldfive[adk]`
class ADKAdapter(AgentAdapter):
def __init__(
self,
agent_or_runner: Any, # google.adk.BaseAgent OR an existing Runner
*,
user_id: str = "goldfive_user",
session_id: str | None = None,
app_name: str | None = None,
plugins: list[Any] | None = None,
agent_tool_cap: int | None = None, # default 16; 0 disables
) -> None: ...
# Overlay-model entry points (goldfive#141). `goldfive.wrap` uses
# ``invoke_passthrough`` exclusively; ``invoke`` and
# ``invoke_follow_up`` are kept for external callers.
async def invoke_passthrough(
self,
user_message: str,
*,
session: Session,
reconciler: Any = None,
ctx: Any = None,
) -> InvocationResult:
"""Drive ONE ADK turn with the user's original request verbatim."""
async def invoke_follow_up(
self, task: Task, session: Session
) -> InvocationResult:
"""Gentle ``Also, please: {title}.`` for a missed task.
Not called by the overlay executor since goldfive#163 —
PENDING tasks land in ``TaskStatus.NOT_NEEDED`` at invocation
end. Retained for external callers.
"""
async def invoke(self, task: Task, session: Session) -> InvocationResult:
"""DEPRECATED — per-task drive. Uses ``invoke_follow_up`` phrasing."""
def add_plugin(self, plugin: Any) -> None: ...
@property
def available_agents(self) -> list[str]:
"""Sorted names of every agent reachable from the wrap target.
Walks ``sub_agents`` / ``inner_agent`` / ``AgentTool.agent``
edges at ``__init__`` time. Advisory for the planner, which
populates ``task.assignee_agent_id`` as a delegation hint;
goldfive does not route on the assignee under the single-
Runner model (goldfive#130).
"""
@property
def available_agents_tree(self) -> list[dict[str, Any]]:
"""Structured walker of the tree: one dict per reachable agent
with ``name`` / ``depth`` / ``parent`` / ``role`` / ``kind``.
Passed to ``LLMPlanner.generate(available_agents=...)`` so the
prompt and validator can enforce on-registry assignees
(goldfive#151).
"""
# Requires `goldfive[claude]`
class ClaudeAgentSDKAdapter(AgentAdapter):
def __init__(
self,
*,
client_factory: Callable[[], Any], # () -> claude_agent_sdk.ClaudeSDKClient
steerer: Optional[Steerer] = None,
system_prompt_template: Optional[str] = None,
model: Optional[str] = None,
available_agents: Optional[list[str]] = None,
) -> None: ...
def bind_steerer(self, steerer: Steerer) -> None:
"""Wire a :class:`Steerer` in after construction."""ADKAdapter.invoke_passthrough(user_message, session=..., reconciler=...)
drives the one runner regardless of task.assignee_agent_id — the
assignee is a planner hint, not a routing key. The plugin enforces a
per-invocation cap on AgentTool spawns (default 16,
agent_tool_cap=0 disables); on exceed the plugin emits a
RUNAWAY_DELEGATION drift at CRITICAL severity and cancels the
invocation.
When the caller or the executor cancels an in-flight invocation,
ADKAdapter invokes plugin.on_cancellation(invocation_id) on every
plugin that defines the method (goldfive#167/#168). Observability
plugins like HarmonografTelemetryPlugin use this to close open
spans with status=CANCELLED before the CancelledError is
re-raised. Exceptions in the hook are swallowed — cancel semantics
take precedence.
See ARCHITECTURE.md §"Single-Runner dispatch"
for the model and adk-web-integration.md §"Pre-built Runner degrade mode"
for the degraded-mode contract when the caller passes a
pre-built Runner instead of a BaseAgent.
class InMemorySink(EventSink):
events: list[Any] # property — the live event list
class LoggingSink(EventSink):
def __init__(
self,
*,
logger: Optional[logging.Logger] = None,
level: int = logging.INFO,
) -> None: ...
class JSONLPersistenceSink(EventSink):
def __init__(
self,
path: str | Path,
mode: Literal["append", "write"] = "append",
) -> None: ...
class SQLitePersistenceSink(EventSink):
def __init__(
self,
path: str | Path,
*,
table: str = "goldfive_events",
) -> None: ...
class GRPCSink(EventSink):
def __init__(
self,
endpoint: str,
*,
credentials: Optional[grpc.ChannelCredentials] = None,
reconnect: bool = True,
max_queue: int = 0, # 0 = unbounded
) -> None: ...
# Module-level replay / reconstruction helpers.
def replay_from_jsonl(path: str | Path) -> list[Any]:
"""Parse a JSONL log written by ``JSONLPersistenceSink`` into Event
proto messages, in emit order."""
def replay_from_sqlite(
path: str | Path,
run_id: str,
*,
table: str = "goldfive_events",
) -> list[Any]:
"""Read a single run's events from a SQLite database, ordered by
sequence."""
def list_runs(
path: str | Path,
*,
table: str = "goldfive_events",
) -> list[str]:
"""Return the distinct run_ids present in a SQLite database."""
def reconstruct_session(events: list[Any]) -> Session:
"""Replay events to rebuild a best-effort :class:`Session`."""LoggingSink, JSONLPersistenceSink, SQLitePersistenceSink,
GRPCSink, and the replay helpers require the proto extra (they
lean on google.protobuf; GRPCSink also requires grpcio). When
the extra is missing the symbols resolve to None at import time
so the rest of the package stays usable.
class GoldfiveIngressServer:
def __init__(
self,
sinks: list[EventSink],
*,
credentials: Any = None,
server_options: list[tuple[str, Any]] | None = None,
) -> None: ...
async def start(self, host: str = "127.0.0.1", port: int = 50051) -> int: ...
async def stop(self, grace: float | None = 1.0) -> None: ...
async def run(self, host: str = "127.0.0.1", port: int = 50051) -> None: ...The companion server for GRPCSink. Receives proto Event messages
over the GoldfiveIngress.StreamEvents RPC (defined in
proto/goldfive/v1/service.proto) and fans them out to local
sinks. See grpc-transport.md.
Primitive for external pause / resume / cancel / steer / rewind / approve / reject. Full design in ../design/CONTROL.md.
class ControlKind(StrEnum):
PAUSE = "PAUSE"
RESUME = "RESUME"
CANCEL = "CANCEL"
STEER = "STEER" # payload: {"note": "...", "suggested_action": "..."}
REWIND_TO = "REWIND_TO" # payload: {"task_id": "..."}
APPROVE = "APPROVE" # payload: {"target_id": "...", "detail": "..."}
REJECT = "REJECT" # payload: {"target_id": "...", "detail": "..."}STATUS_QUERY and INTERCEPT_TRANSFER are not in the Phase-1 enum
but are accepted as raw strings by the executor's dispatcher.
class AckResult(StrEnum):
SUCCESS
FAILURE
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 = 0class ControlChannel:
def __init__(self) -> None: ...
async def send(self, msg: ControlMessage) -> None: ...
async def receive(self, timeout: float | None = None) -> ControlMessage | None: ...
async def ack(self, ack: ControlAck) -> None: ...
def acks(self) -> AsyncIterator[ControlAck]: ...
def close(self) -> None: ...Bidirectional: external callers push via send and iterate acks via
acks(); the runner consumes via receive() and publishes via
ack(). Dependency-light (asyncio.Queue under the hood) — tests
instantiate directly.
@dataclass
class ReportingToolSpec:
name: str
description: str
parameters: dict[str, Any] # JSON schema
handler: Callable[
[dict[str, Any], Session, Steerer],
Awaitable[dict[str, Any]],
]
# The ten canonical tool names (stable contract).
REPORTING_TOOL_NAMES: tuple[str, ...] = (
"report_task_started",
"report_task_progress",
"report_task_completed",
"report_task_failed",
"report_task_blocked",
"report_new_work_discovered",
"report_plan_divergence",
"report_awaiting_approval",
"declare_task_skipped",
"declare_task_not_needed",
)
# Pre-built list of the ten canonical specs. ``Runner`` registers the
# seven-tool lifecycle subset (LIFECYCLE_REPORTING_TOOLS) with the
# adapter automatically; the three drift self-reporting tools
# (report_plan_divergence, declare_task_skipped,
# declare_task_not_needed) are opt-in via
# ``Runner(drift_self_reporting=...)``. Custom adapters can consume
# the lists directly.
BUILTIN_REPORTING_TOOLS: list[ReportingToolSpec]
LIFECYCLE_REPORTING_TOOLS: list[ReportingToolSpec]
DRIFT_SELF_REPORTING_TOOLS: list[ReportingToolSpec]See tool-protocol.md for full semantics.
def new_event(run_id: str, sequence: int) -> Any:
"""Fresh envelope with run_id, sequence, emitted_at populated."""
def make_event(
*,
run_id: str,
sequence: int,
kind: str,
payload: dict[str, Any],
) -> Any:
"""Build an envelope from a ``kind`` + ``payload`` dict. Falls back
to a plain dict when the ``proto`` extra is not installed."""
def now_ts() -> Any:
"""Current wall-clock time as a proto ``Timestamp``."""
async def emit(sinks: list[EventSink], event_pb: Any) -> None:
"""Fan out to every sink; swallow per-sink exceptions."""Per-event builders (run_started_event, run_completed_event,
run_aborted_event, goal_derived_event, plan_submitted_event,
plan_revised_event, task_started_event, task_progress_event,
task_completed_event, task_failed_event, task_blocked_event,
task_cancelled_event, drift_detected_event) are also available in
goldfive.events as convenience constructors for sink implementations.
Three observability-only events emitted by the goldfive ADK plugin on every dispatch. They describe the "who actually ran what" shape of a run — both goldfive's top-level dispatch and any AgentTool-spawned sub-Runner invocations. See EVENT-MODEL.md §"Agent-invocation events" for the nesting rules and consumer guidance.
def agent_invocation_started_event(
run_id: str,
sequence: int,
*,
agent_name: str,
task_id: str = "",
invocation_id: str = "",
parent_invocation_id: str = "",
started_at: Any | None = None,
) -> Any: ...
def agent_invocation_completed_event(
run_id: str,
sequence: int,
*,
agent_name: str,
task_id: str = "",
invocation_id: str = "",
summary: str = "",
completed_at: Any | None = None,
) -> Any: ...
def delegation_observed_event(
run_id: str,
sequence: int,
*,
from_agent: str,
to_agent: str,
task_id: str = "",
invocation_id: str = "",
observed_at: Any | None = None,
) -> Any: ...AgentInvocationStarted payload fields:
| Field | Type | Meaning |
|---|---|---|
agent_name |
string |
Dispatched agent. task.assignee_agent_id for top-level; wrapped agent's name for AgentTool sub-Runners. |
task_id |
string |
Goldfive-dispatched task id. Propagates into nested invocations. |
invocation_id |
string |
ADK's per-run invocation id. |
parent_invocation_id |
string |
Empty on top-level; set to outer invocation_id on AgentTool sub-Runners. |
started_at |
Timestamp |
Emission time. |
AgentInvocationCompleted payload fields:
| Field | Type | Meaning |
|---|---|---|
agent_name |
string |
Matches the corresponding Started event. |
task_id |
string |
Same as the Started event. |
invocation_id |
string |
Matches the Started event. |
summary |
string |
Optional outcome summary (final assistant text). |
completed_at |
Timestamp |
Emission time. |
DelegationObserved payload fields:
| Field | Type | Meaning |
|---|---|---|
from_agent |
string |
Host agent about to call the AgentTool. |
to_agent |
string |
Wrapped agent the AgentTool will invoke. |
task_id |
string |
Goldfive-dispatched task id. |
invocation_id |
string |
Host agent's invocation id. |
observed_at |
Timestamp |
Emission time. |
Generated stubs committed to git. Key messages:
# types_pb2
Goal, Plan, Task, TaskEdge, TaskStatus, DriftKind, PlanRevision
# events_pb2
Event, RunStarted, GoalDerived, PlanSubmitted, PlanRevised,
TaskStarted, TaskProgress, TaskCompleted, TaskFailed,
TaskBlocked, TaskCancelled, DriftDetected, RunCompleted, RunAborted,
AgentInvocationStarted, AgentInvocationCompleted, DelegationObserved
# control_pb2
ControlEvent, ControlAck, ControlKind, ControlAckResult, ControlTarget,
SteerPayload, RewindPayload, ApprovePayload, RejectPayload,
InjectMessagePayloadThe Event envelope has a oneof payload with one field per
per-event message. See EVENT-MODEL.md for
the full catalog.
Envelope fields worth knowing about:
| # | Field | Added | Purpose |
|---|---|---|---|
| 1 | event_id |
v0.1 | UUIDv7 recommended for sink dedupe. |
| 2 | run_id |
v0.1 | Stable across every event in a run. |
| 3 | sequence |
v0.1 | Per-run monotonic, gap-free starting at 0. |
| 4 | emitted_at |
v0.1 | Wall clock; advisory only. |
| 5 | session_id |
goldfive#155/#157 | Per-event Session.id for stream-multiplexed consumers. Empty means "route via stream Hello". |
Drift-side augmentations:
DriftDetected.annotation_id(field 6, goldfive#176/#177) — source annotation id for USER_STEER / USER_CANCEL drifts minted from aControlMessage; empty for goldfive-minted drifts. Sinks dedupe against the annotation card.
Control-side augmentations (SteerPayload, goldfive#171/#175):
| # | Field | Purpose |
|---|---|---|
| 1 | note |
Steer body text. |
| 2 | suggested_action |
Optional hint to the planner. |
| 3 | author |
Operator identity from the originating annotation; empty when the bridge doesn't source annotations. |
| 4 | annotation_id |
Source annotation id used for idempotent delivery in DefaultSteerer.observe. |
Proto ↔ dataclass round-trip:
def to_pb_plan(plan: Plan) -> pb.Plan: ...
def from_pb_plan(msg: pb.Plan) -> Plan: ...
def to_pb_goal(goal: Goal) -> pb.Goal: ...
def from_pb_goal(msg: pb.Goal) -> Goal: ...
def to_pb_task(task: Task) -> pb.Task: ...
def from_pb_task(msg: pb.Task) -> Task: ...
def to_pb_task_edge(edge: TaskEdge) -> pb.TaskEdge: ...
def from_pb_task_edge(msg: pb.TaskEdge) -> TaskEdge: ...
def to_pb_drift_event(evt: DriftEvent) -> pb.DriftEvent: ...
def from_pb_drift_event(msg: pb.DriftEvent) -> DriftEvent: ...def replay_from_jsonl(path: str | Path) -> list[Any]:
"""Parse a JSONL log into ``Event`` proto messages, in emit order."""
def replay_from_sqlite(path: str | Path, run_id: str) -> list[Any]:
"""Read a single run's events from a SQLite database."""
def reconstruct_session(events: list[Any]) -> Session:
"""Replay events to rebuild a best-effort :class:`Session`."""Used by Runner.resume(). See
persistence-and-recovery.md.
For custom steerer subclasses that want to compose the standard classifiers:
def classify_tool_error(event: Any) -> Optional[DriftEvent]: ...
def classify_refusal(text: Any) -> Optional[DriftEvent]: ...
def classify_stop_reason(reason: Any) -> Optional[DriftEvent]: ...Each returns None when the input does not match the classifier's
signal; callers compose them into a steerer's detect_drift
pipeline.
classify_refusal grades severity by matching tier:
| Tier | Tuple | Severity | Example marker |
|---|---|---|---|
| Policy | LLM_REFUSAL_MARKERS_CRITICAL |
CRITICAL |
"i must decline" |
| Capability | LLM_REFUSAL_MARKERS_WARNING |
WARNING |
"i cannot" |
| Hedging | LLM_REFUSAL_MARKERS_INFO |
INFO |
"i'm not confident" |
The scan order is CRITICAL -> WARNING -> INFO; first match wins, so
a policy/safety refusal is never downgraded when the same text also
contains a capability or hedging marker. DefaultSteerer only
triggers planner.refine for WARNING/CRITICAL drift; INFO
matches are emitted to sinks for observability only.
The module also exposes the context-pressure table
CONTEXT_PRESSURE_STOP_REASONS: tuple[str, ...] for downstream
reuse. The flat LLM_REFUSAL_MARKERS tuple is retained as the
concatenation of the three tiered tuples for back-compat; new code
should import the tiered names directly.
- v0.1 — the shapes in this document. Stable within v0.1.x patch releases.
- v0.2+ — shapes may be extended (new fields, new enum values, new methods on protocols with defaults). Anything in this doc is expected to stay backwards-compatible through v1.0.
- Proto compatibility — proto3 rules. Never remove or renumber a
field; add new events by extending the
oneof payload.
Anything not documented here is internal and subject to change without notice. Use the public surface.