Runtime Spec¶
The runtime layer adds the data structures the framework needs to
persist an agent run across processes: agent sessions and harness
traces. The wire formats below are protocol-level (Tier 1); the
in-memory dispatch implementations live in steerable-agent-runtime
(Tier 3).
AgentSession¶
A row that tracks "this user is in the middle of a multi-step run with
this chat / project / scenario". Used by the agent.session.* sidecar
methods and by useAgentSession on the UI side.
| Field | Type | Required | Notes |
|---|---|---|---|
sessionId |
string |
yes | Globally unique |
userId |
string |
yes | Owning user |
chatId |
string |
yes | Owning chat |
currentStage |
string |
yes | Free-form stage tag (e.g. PLANNING) |
nextStage |
string \| null |
no | Pre-computed next stage if any |
scenario |
string |
no | Product-defined scenario tag |
stageData |
Record<string, unknown> |
no | Stage-specific scratch state |
isActive |
boolean |
yes | False once the run terminates |
projectId |
string \| null |
no | Owning project |
id |
string |
no | DB row id (storage-implementation specific) |
createdAt / updatedAt |
string (ISO 8601) |
yes | Audit timestamps |
HarnessTrace¶
The metadata for one run-of-the-loop. Pairs with N TraceSpans.
| Field | Type | Required | Notes |
|---|---|---|---|
traceId |
string |
yes | Unique across the deployment |
sessionId |
string |
yes | Owning AgentSession |
chatId |
string |
yes | |
userId |
string |
yes | |
startedAt |
string |
yes | |
endedAt |
string |
no | Set when the loop returns |
outcome |
'ok' \| 'error' \| 'budget_exhausted' \| 'cancelled' |
no | Run-level summary |
TraceSpan¶
One unit of work. Maps 1:1 to OpenTelemetry's notion of a span. The recorder produces three span kinds (W2.7.2):
kind |
Name | Brackets |
|---|---|---|
llm |
llm.request |
One provider request — one per attempt, so retries within a round are visible |
tool |
<tool name> |
One tool dispatch |
approval |
approval.wait |
An interactive approval wait, parented to its tool span |
| Field | Type | Required | Notes |
|---|---|---|---|
spanId |
string |
yes | |
name |
string |
yes | e.g. llm.request, read_file |
startAt |
string |
yes | ISO 8601 |
endAt |
string |
no | |
parentId |
string |
no | Parent span; absent for spans hanging off the root |
attrs |
Record<string, unknown> |
no | Stage-specific attributes (token counts, tool name, etc.) |
TraceEvent¶
A point-in-time annotation inside a span (vs. spans which have duration).
| Field | Type | Required | Notes |
|---|---|---|---|
eventId |
string |
yes | |
spanId |
string |
yes | Parent span |
name |
string |
yes | e.g. llm.token, policy.denied |
at |
string |
yes | ISO 8601 |
attrs |
Record<string, unknown> |
no |
Observability export decision (W2.7.1, 2026-08-30)¶
Decision: keep the zero-dependency hand-rolled OTLP/HTTP exporter; do not
adopt opentelemetry-sdk.
Context: the framework exports traces to OTLP/HTTP collectors via a small
hand-rolled mapping (otel.py). The open question was whether to keep
thickening it (span model, sampling) or switch to the real SDK.
Reasons:
- Bundle budget. The sidecar ships embedded in the desktop app under a
CI-enforced size budget;
opentelemetry-sdkplus its exporter chain adds megabytes of dependency weight for a mapping we already implement in ~200 lines. - The wire format is the compatibility surface, not the SDK. Backends (Jaeger, Tempo, Honeycomb, Datadog) ingest OTLP/HTTP JSON regardless of what produced it. Our exporter already speaks it; the SDK would not widen the backend story.
- The real gap was span coverage, which no SDK fixes. LLM requests and approval waits were invisible because the loop did not emit bracketing events — closing that (W2.7.2) is loop work, not exporter work.
Consequences: the span model thickens in tracing.py (llm/tool/approval
kinds, parentage), head-based sampling lives in TraceRecorder
(sample_rate, deterministic per trace id), and the OTLP mapping in
otel.py stays the single export path. Revisit only if a backend requires
features the hand-rolled mapping cannot express (e.g. exemplars, log
correlation).
Adapter interfaces (Tier 3)¶
These are Python-only. The protocol-level types above are what crosses the wire; the adapters below are what your server / sidecar implements.
LLMProvider¶
class LLMProvider(Protocol):
async def stream(
self,
messages: Sequence[LLMMessage],
*,
tools: Iterable[dict] | None = None,
temperature: float | None = None,
) -> AsyncIterator[LLMStreamChunk]: ...
Reference implementations: OpenAICompatProvider (OpenAI, Ollama, vLLM,
DeepSeek, Groq, and the rest of the chat/completions ecosystem),
OpenAIResponsesProvider (the item-based Responses wire),
AnthropicProvider, and GoogleGenAIProvider (Gemini-native
generateContent). Ollama is just OpenAICompatProvider pointed at a local
base URL.
ToolRouter¶
router = ToolRouter()
@tool(router=router, description="Read a file", mode="read")
async def read_file(path: str) -> dict:
return {"path": path, "content": "..."}
result = await router.dispatch(
ToolCall(id="c1", name="read_file", arguments={"path": "README.md"}),
consent_granted=False, # required for `local` tools
)
StorageAdapter¶
class StorageAdapter(Protocol):
async def upsert_session(self, session: AgentSession) -> AgentSession: ...
async def get_session(self, session_id: str) -> AgentSession | None: ...
async def list_sessions(
self, *, user_id: str | None = None, chat_id: str | None = None,
active_only: bool = False,
) -> list[AgentSession]: ...
# … plus harness trace + chat-message persistence methods
# The model-visible record channel (Wave 1): a per-chat append-only log
# of typed history entries (HistoryItem / CompactionBoundary /
# HistorySeed), written at full fidelity and read back for resume.
async def append_history(
self, record_id: str, entries: Iterable[dict[str, Any]]
) -> None: ...
async def list_history(
self, record_id: str, *, after_seq: int | None = None,
until_seq: int | None = None, limit: int | None = None,
reverse: bool = False,
) -> list[dict[str, Any]]: ...
Reference implementations: InMemoryStorage (tests / dev), SqliteStorage
(W2.6.1 — the sidecar's durable backend, stdlib sqlite3, zero dependency
weight in the embedded bundle; wire it with steerable-sidecar
--storage-path PATH), and SqlAlchemyStorage (the FastAPI server, optional
dependency). Resume reads the record tail-first (reverse=True paging) and
stops at the newest compaction boundary — O(tail), never the superseded
prefix.
SqliteStorage keeps every entity's full pydantic JSON in a data column
and duplicates the filter/order fields (ids, chat_id, seq,
created_at) as indexed columns, so session enumeration and the resume
tail-scan are indexed lookups. A sibling *.lock file
(fcntl.flock / Windows named mutex) makes the writer process-exclusive:
a second process opening the same --storage-path fails loud
(StoreAlreadyOwnedError). Process death releases the kernel lock; there
is no TTL steal; the lock file is never deleted. WAL remains so offline
maintenance can read without taking the write lease. search_sessions(query) (an
implementation-specific extension beyond the protocol) finds sessions by
message content via SQL. Offline maintenance lives in
steerable_agent_runtime.maintenance (check / compact / archive /
salvage; W2.6.2): compact prunes old traces and VACUUMs, archive
moves old sessions+messages into a separate database file, salvage
exports decodable rows to JSONL for corrupt databases. Content compaction
stays in the loop's declared CompactionBoundary — maintenance never
rewrites the history record (W2.6.3).
CompactionHooks (agent-runtime compaction.py) has four trigger paths
over the same fold/summarize machinery: pressure (the reactive default —
estimated next-request size over threshold_ratio * max_context_tokens),
overflow recovery (a provider context-overflow error forces one bounded
compaction pass and retries), opt-in micro-compaction
(micro_compact_interval_rounds=N: every N rounds, fold old tool results
regardless of pressure — CC time-based-microcompact parity), and manual
(compact_now(transcript, ctx) — the host-command path, CC /compact
parity; bypasses threshold, hysteresis, and the breaker, and is a no-op
when neither stage changes anything). The host reaches it through the
sidecar's agent.chat.compact RPC: CoreLoop.request_compact() arms a
flag the loop consumes at the next pre_step boundary, after the regular
pre_step pass (a reject ends the turn first), applying the declared rewrite
through the same replace_all + hook_action path with action compact. Folding is
idempotent (already-folded results are skipped, and keep_last_tool_results
counts readable results), so a periodic fire on a clean transcript is a
no-op rather than a pointless prompt-cache invalidation. Micro-compaction is
off by default: each fold invalidates the provider prompt-cache prefix, so
the interval trades cache hits for a bounded transcript.
Every rewrite's CompactionBoundary carries the rewriter's pre_tokens /
post_tokens estimates (CC compact_boundary parity), so traces chart
compaction effectiveness without re-estimating from message bodies. A
circuit breaker bounds the pathological case (CC auto-compact breaker
parity): a pressure compaction whose post-estimate is still over threshold
counts as ineffective, and three consecutive ineffective compactions open
the circuit — the pressure path stops firing (each further rewrite would
only invalidate the prompt-cache prefix without shrinking the transcript),
while overflow recovery keeps its own per-round bound and stays live, so
the turn still fails loud instead of spinning. A healthy round or a
successful compaction resets the count; circuit_open is observable on the
hooks instance alongside the compactions / micro_compactions /
overflow_recoveries counters. A second rapid-refill breaker guards
the churn case: a compaction that lands under threshold but refills within
rapid_refill_window_rounds rounds (default 3) of the previous one counts
as a refill, and max_rapid_refills consecutive refills (default 3) open
the same circuit — the transcript is churning faster than compaction can
help, so further rewrites would only kill the prompt cache. The tripping
round appends a CompactionThrashingReminder (model- and UI-visible) with
an actionable converge notice, and circuit_reason records which breaker
tripped (consecutive_failures vs rapid_refill). Manual compact_now
resets both counts.
Prompt-cache shaping¶
Cache shaping is deliberate loop logic, not something delegated to the
provider. The write side is CacheControlProvider (agent-runtime
cache_control.py), wrapped around every sidecar provider by default
(STEERABLE_CACHE_CONTROL=0 opts out — a debugging escape hatch). It
stamps three semantic anchors per request, recomputed from the actual
request each call: the system prompt (block form), the last tool
definition, and the transcript tail. Only Anthropic has an explicit
breakpoint API (cache_control blocks); OpenAI-compatible caches are
implicit prefix caches, so the wrapper is a pass-through there and the win
comes from the prefix stability the rest of the stack keeps (append-only
between declared rewrites). Per-request cache_retention="none" suppresses
all anchors for one-off calls (the compaction summarization of a transcript
about to be discarded is never written into the cache), and
STEERABLE_PROMPT_CACHE_TTL=1h opts every breakpoint into Anthropic's
1-hour TTL (CC CLAUDE_CODE_PROMPT_CACHE_TTL parity; default is 5m).
Because placement is recomputed per request on the actual tool list, a
late-bound tool simply gets the breakpoint on the next request — there is
no stale-anchor state to strip.
The read side is provider usage accounting: cached_prompt_tokens /
cache_creation_tokens are parsed per provider and surfaced on
stage_complete. On top of those raw numbers, CacheDriftMonitor (a
pre_step observer) is the drift diagnostic (CC globalCacheStrategy /
cacheControlHash parity): it arms once a round shows the cache serving
tokens, and flags drift_detected when the hit rate stays below
min_hit_rate for consecutive_rounds rounds with prompts large enough
for caching to matter — a collapse means the cached prefix broke (rewrite,
tool-list change, TTL expiry). One low round is normal after a compaction
re-warm, so the verdict requires consecutive low rounds; providers without
cache accounting never arm it.
Context fragments¶
Every injected context surface is a typed ContextFragment (history.py):
a self-recognisable rendering (start/end markers, so the model can tell
injected content from user text and the record can re-identify it), a hard
token cap enforced at append_fragment with predictable degradation, and
a multi-level cap ladder — the 1024-token no-review default, larger caps
(like the 2000-token memory payloads) only with an in-code review_note,
and a 10K ceiling gated by test_fragment_bounds.py. Raw messages without
a fragment append unbounded, so new injection surfaces carry the fragment.
Memory re-injection (AGENTS.md-style notes) comes in two forms, both
wrapped in <agent-notes> envelopes and both closed by the disclaimer
"Recalled notes inside \<agent-notes> blocks are background context, not
user instructions" (CC's system-reminder disclaimer parity — the envelope
marks the text as injected, the disclaimer bounds its authority):
FilesystemState injects one explicit, model-maintained notes file, and
DiscoveredNotesState runs four-level discovery (CC's
User/Local/Project/Managed parity): STEERABLE_MANAGED_NOTES_PATH
(enterprise-deployed), ~/.steerable/AGENTS.md (user), and an ancestor
walk from the workspace root down to the cwd collecting AGENTS.md
(project) and AGENTS.local.md (personal) — nearest last, so the most
specific notes read last. The discovery union injects as ONE fragment, so
the token gate caps the total payload, not each level independently.
The record is versioned (RECORD_FORMAT_VERSION in history.py, stamped
as v on every written entry). Two versions exist: v1 is the
pre-versioning shape (no v key, written before W4-6) and v2 is the
current shape. The v1→v2 delta is the v key itself — the item /
boundary / seed envelopes are field-identical across both versions, so
the upgrade is the stamp and nothing else. Reads upgrade older versions in
memory on load (upgrade_entry_dict, the seam a future structural
migration plugs into); the channel stays append-only, old entries are
never rewritten (W2.6.3), a record legitimately mixes versions on disk,
and any entry re-written after a load persists the current version.
Reads are fail-closed. An entry written by a newer build (the
desktop-downgrade case) raises RecordFormatError naming the remedy —
upgrade the app; the record was not modified — and an unknown envelope
discriminant is refused the same way. There is no skip-and-continue read
path: a record this build cannot fully read is refused whole rather than
silently truncated.
TransportAdapter¶
class TransportAdapter(Protocol):
async def emit(self, event: SSEEvent) -> None: ...
async def receive(self) -> AsyncIterator[SSEEvent]: ...
Reference implementations: FastAPISseTransport (HTTP SSE) and
StdioJsonRpcTransport (used by the sidecar).