This RFC codifies the direction discussed in #1925. It does not introduce an implementation. The central guardrail comes from Michael Lam’s review framing:
the adapter should be a protocol translator, not a runtime surrogate.
The product boundary from #1925 is:
WebUI should be thin in execution ownership, not thin in product scope.
That means WebUI remains the full browser workbench for sessions, workspace files, chat rendering, tool cards, approvals, status, diagnostics, and controls. The change is that long-lived execution ownership should move behind an explicit runtime boundary instead of remaining scattered through the main WebUI request process.
This document is intentionally a reviewable spec and migration gate. It should be accepted before any implementation PR for this adapter direction changes the streaming hot path, introduces a runner process, or moves a new approval / clarify / queue / goal control path. Narrow current-path bug fixes that do not introduce a new runtime boundary can still proceed under the WebUI run-state consistency contract and the relevant issue scope.
Browser-originated chat turns are still executed inside the WebUI server process.
The current path creates process-local stream state, starts background agent
threads, constructs or reuses AIAgent, and owns callback state for token, tool,
reasoning, approval, clarify, cancellation, and terminal events.
That shape works, but it makes the WebUI process the owner of active runtime truth. Consequences include:
The immediate goal is not to build a sidecar. The immediate goal is to define the browser contract, classify current runtime state, and gate the first reversible journal slice.
Slice 1 is now past the first active validation gate:
origin/master passed on 2026-05-16. The matrix covered completed-run replay,
interrupted stale-pending recovery, fresh-pending grace handling, StreamChannel
reconnect ordering, duplicate-prevention merge behavior, many-session recovery,
large-journal derivation, and stream-to-turn-id lifecycle linking.tests/test_turn_journal.py tests/test_turn_journal_lifecycle.py tests/test_stale_stream_pending_recovery.py
also passed on the same worktree./api/chat/stream
EventSource per active session.This evidence did not prove the future runner/sidecar path. It did unblock the adapter-seam work:
legacy-journal RuntimeAdapter seam in
v0.51.81./api/chat/start’s public JSON contract.RuntimeAdapter.cancel_run(...) only when
ARES_WEBUI_RUNTIME_ADAPTER=legacy-journal is enabled.RuntimeAdapter.update_goal(...) only when
ARES_WEBUI_RUNTIME_ADAPTER=legacy-journal is enabled, while preserving the
legacy-direct response shape and leaving post-turn goal evaluation in the
existing agent loop.queue_message(...) as a staged protocol method only; /queue remains
browser-side queue/drain behavior, and no server-side queue endpoint or queue
scheduler should be added merely for adapter symmetry.RunnerRuntimeAdapter facade in v0.51.94. The
facade normalizes an injected runner client’s start / observe / status /
control responses without owning AIAgent, streams, cancellation flags,
approval queues, clarify queues, goal state, or cached-agent tables.runner-local adapter selection point and build_runtime_adapter(...)
factory wiring around an injected runner client. Live browser chat routes still
stay on the legacy backend, and no supervised runner process exists yet./api/chat/start. It should only engage when runner-local is explicitly
selected, return a bounded not-configured error until a supervised runner
client exists, keep legacy-direct / legacy-journal fallback intact, pass
explicit profile/workspace/model payloads instead of mutating WebUI process
globals, and avoid recreating STREAMS / CANCEL_FLAGS / approval queues /
clarify queues under new names.The next gate is runner-backend plumbing, not queue implementation
by default. Queue / continue routing should only move before Slice 4 if a future
maintainer decision identifies an existing server-side legacy entry point and
pins its response shape, ordering, and idempotency contract. Otherwise, keeping
queue_message(...) staged is the honest boundary while execution ownership
moves out of the main WebUI request process.
runner process,
journal, adapter API surface, or WebUI presentation cache._run_agent_streaming control flow in the first journal slice.STREAMS, cached AIAgent objects, callback queues, or
cancellation flags under new names.This is the compatibility contract the browser depends on, regardless of whether
the backend is today’s in-process streaming path, an in-process journaled path, a
future WebUI-managed runner, or a future Ares /v1/runs backend.
The current inventory should be derived from static/messages.js consumers and
SSE/event production in api/streaming.py. Future edits to those files should
update this RFC or the implementation contract that replaces it.
Every replayable runtime event should be representable with:
{
"event_id": "run_123:42",
"seq": 42,
"run_id": "run_123",
"session_id": "20260514_...",
"type": "tool.updated",
"created_at": 1778750000.0,
"terminal": false,
"payload": {}
}
Required semantics:
seq is monotonic per run.event_id is stable enough to use as an SSE id: value or equivalent cursor.Last-Event-ID or after_seq.run_id + seq or event_id.done, cancelled, or error state.| Event family | Required payload | Browser responsibility | Runtime source of truth |
|---|---|---|---|
run.started / status |
lifecycle state, controls available, session id, workspace/profile/model/toolset summary | render active state and controls | runtime run state |
token.delta |
assistant message id or segment id, delta text, content type | append visible assistant text | runtime model output stream |
reasoning.delta / reasoning.done |
reasoning block id, delta/final text, visibility metadata | render thinking/progress UI | runtime reasoning events |
progress |
concise phase/status text, optional tool context | render activity/progress text | runtime progress callbacks |
tool.started |
tool call id, name, sanitized arguments, start time | open/update tool card | runtime tool lifecycle |
tool.updated |
stdout/stderr/structured partial data, progress metadata | update tool card | runtime tool lifecycle |
tool.done |
result, status/exit code, duration, error flag | finalize tool card | runtime tool lifecycle |
approval.requested |
approval id, action summary, risk metadata, available choices | show approval widget | runtime approval state |
approval.resolved |
approval id, choice, resulting status | close/update approval widget | runtime approval state |
clarify.requested |
clarify id, question, choices/input mode | show clarify widget | runtime clarify state |
clarify.resolved |
clarify id, answer metadata/status | close/update clarify widget | runtime clarify state |
title.updated |
title text, source/confidence | update title surfaces | session/title subsystem |
usage.updated / usage.final |
tokens, cost, model/provider, duration where available | update usage surfaces | runtime usage accounting |
error |
stable error code, safe message, redacted diagnostics, terminal flag | render error and final state | runtime terminal/error state |
done |
final lifecycle state, usage, terminal result/error summary, last seq | finalize run UI | runtime terminal state |
Every active or terminal run must expose:
run_idsession_idstatus: queued, running, awaiting_approval, awaiting_clarify,
paused, cancelling, cancelled, failed, completed, or expiredlast_event_id| Control | Required semantics | Target owner |
|---|---|---|
| observe | attach to live events and replay from cursor | adapter API surface backed by runtime/journal |
| status | poll lifecycle state when SSE/WebSocket is unavailable | adapter API surface backed by runtime/journal |
| cancel | request graceful cancellation; terminal event follows | runner/runtime control plane |
| queue / continue | append follow-up work according to Ares semantics | runner/runtime control plane |
| approval | resolve pending approval by id with supported choices | runner/runtime control plane |
| clarify | answer pending clarify request by id | runner/runtime control plane |
| goal | set/status/pause/resume/clear goal where capability exists | runtime command/capability plane |
WebUI may keep presentation state such as expanded rows, selected tabs, and local scroll position. WebUI must not privately mutate runtime truth for these controls.
Classifications:
runner process: should be owned by the eventual execution runner / runtime
backend, not the main WebUI request process.journal: should be captured in append-only durable events for replay and
diagnostics.adapter API surface: should be exposed through a WebUI-owned boundary that
can later switch backend implementations.WebUI presentation cache: may remain local because it is not execution truth.| Current primitive | Current legacy source of truth | Target classification | Future backend mapping | Slice 1 handling | Notes / gap |
|---|---|---|---|---|---|
STREAMS / STREAMS_LOCK |
api.state_sync process memory |
adapter API surface + presentation fan-out | WebUI runner or future Ares run observation API | keep live path; mirror events into journal | Must stop being authoritative for active run existence. |
CANCEL_FLAGS |
api.state_sync process memory |
runner process | cancel/interrupt endpoint or runner control | no control-flow change | Final cancel state must return as a replayable event. |
cached AIAgent objects / AGENT_INSTANCES |
api/config.py process memory |
runner process | runner-owned Ares integration | unchanged | Moving this is deferred until after journal proof. |
| background thread lifecycle | _run_agent_streaming in api/streaming.py |
runner process | runner-owned execution lifecycle | unchanged | Slice 1 must not rewrite thread/control flow. |
| token / partial text buffers | streaming callbacks and browser SSE state | journal + presentation cache | replayable runtime events | append emitted events | Browser can cache rendered state, but replay must rebuild it. |
| reasoning buffers | streaming callbacks and UI rendering state | journal + presentation cache | replayable reasoning events | append emitted events | Thinking cards must survive reconnect. |
| tool buffers / live tool calls | WebUI streaming callbacks | journal + presentation cache | replayable tool lifecycle events | append emitted events | WebUI owns rendering, not tool execution state. |
| approval callbacks / queues | live Python callbacks | runner process + adapter API surface + journal | approval state/control endpoint | journal request/resolution events only | Pending approval must eventually survive WebUI restart. |
| clarify callbacks / queues | live Python callbacks | runner process + adapter API surface + journal | clarify state/control endpoint | journal request/resolution events only | Pending clarify must eventually survive WebUI restart. |
per-request ARES_HOME env mutation lock |
api/streaming.py / config helpers |
runner process | runner/profile execution context | unchanged | Long-term runner must isolate profile env without process-global mutation. |
| session-to-active-run mapping | session JSON + active stream ids + memory | journal + adapter API surface | runtime run registry/session mapping | journal run metadata | Reopen session must discover active/completed run. |
| title generation state | WebUI callbacks/session saves | journal + presentation cache | runtime/session title event | append title events | WebUI may display title updates after event receipt. |
| usage accounting state | WebUI callbacks/session saves | journal + presentation cache | runtime usage event/source of truth | append usage events | Avoid divergent WebUI-only accounting. |
| command capability metadata | WebUI command registry + Ares command assumptions | adapter API surface | runtime command/capability metadata | unchanged | Unknown command support should not be guessed by WebUI. |
| voice mode state | browser/UI + streaming path | presentation cache + adapter API surface | runtime input/control capability | unchanged | Acceptance tests must pin voice behavior before migration. |
| project/workspace context | WebUI session/workspace state + env mutation | adapter API surface + runner process | runtime run context | unchanged | Must preserve workspace-aware chat and project context. |
Unclassified state is a design blocker. If an implementation slice discovers a runtime primitive that does not fit this table, update the RFC before landing code.
These are the user-observable behaviors that must survive the migration. The catalog should become automated tests where practical. Where full automation is not feasible in the first slice, the PR must include the strongest practical diagnostic or manual validation plan.
| Behavior | Acceptance criterion | Why it matters | First slice that must prove it |
|---|---|---|---|
| Journal replay after refresh/reconnect | reconnect or restart after events have been journaled can replay from cursor without duplicate transcript/tool/reasoning state | proves the browser contract is replayable and duplicate-safe | journal/replay slice |
| Terminal replay | completed/failed/cancelled runs replay terminal state and do not duplicate transcript content | prevents stale spinner and duplicate-message regressions | journal/replay slice |
| Interrupted/stale run diagnostics | if WebUI restarts while execution is still owned by the WebUI process, replay shows the last journaled state and a clear interrupted/stale diagnostic instead of pretending the run kept executing | keeps slice 1 honest before a runner exists | journal/replay slice |
| Execution survives WebUI restart | active execution outlives the main WebUI process, reconnect discovers the active run, ordered replay catches up, and controls such as cancel still work | proves execution ownership actually moved out of the request process | runner/sidecar or external-runtime slice |
| Cancel during tool call | cancel emits one terminal cancelled state and no stale writeback | catches historical stream ownership races | control migration slice |
| Cancel during reasoning | partial/reasoning content is preserved cleanly and final state is not provider-error | catches cancellation classification regressions | control migration slice |
| Approval request/response | approval survives observation, browser response reaches runtime, result is replayable | approval callbacks are cross-cutting and easy to orphan | approval migration slice |
| Clarify request/response | clarify survives observation, browser response reaches runtime, result is replayable | same risk as approval, different UI/control path | clarify migration slice |
| Slash commands | /compress, /branch, /retry, and other supported commands keep current semantics |
command behavior should not be reimplemented ad hoc | command capability slice |
| Model switch mid-session | provider/model changes route through the correct runtime context | prevents provider/source-of-truth drift | adapter control slice |
| Workspace context | run receives the session workspace and attachments context | preserves workbench value | adapter control slice |
| Multi-profile isolation | profile-specific runs write/read the correct Ares home and memory | protects #2134-family isolation concerns | runner/profile slice |
| Queue/continue | follow-up input during live/resumable work obeys Ares semantics | prevents parallel continuation model | control migration slice |
| Goal continuation | goal status/control survives the adapter boundary | goal logic is lifecycle-sensitive | goal capability slice |
| Voice mode | voice-originated input uses the same run/event/control contract | prevents alternate input path drift | adapter parity slice |
| Projects context | project metadata remains visible and correct across run replay | preserves session/workbench organization | adapter parity slice |
Scope:
Revert path: revert the docs PR.
Pre-authorized only after this spec is reviewed and accepted in #1925.
Scope:
_run_agent_streaming control flow unchanged,Non-goals:
STREAMS as the live delivery path,Revert path:
Success criterion:
Status as of 2026-05-17: shipped. PR #2416 defined the adapter-seam contract,
PR #2424 added the default-off LegacyJournalRuntimeAdapter seam, and PR #2438
kept /api/chat/start response shape identical between legacy-direct and the
flagged legacy-journal path. Slice 2 remains a reversible boundary change, not
a sidecar or execution-ownership move.
Scope:
RuntimeAdapter interface only after Slice 1 proves replay,Revert path: switch the feature flag back to direct legacy path.
The Slice 2 seam should introduce a deliberately small RuntimeAdapter boundary
without changing the execution backend. The first implementation is a
LegacyJournalRuntimeAdapter that delegates to the existing WebUI-owned
streaming path and reads the Slice 1 journal for status/replay. That makes the
adapter a protocol translator over the current backend, not a new runtime owner.
Minimal interface shape:
class RuntimeAdapter:
def start_run(self, request: StartRunRequest) -> RunStartResult: ...
def observe_run(self, run_id: str, *, cursor: str | None = None) -> RunEventStream: ...
def get_run(self, run_id: str) -> RunStatus: ...
def cancel_run(self, run_id: str) -> ControlResult: ...
def respond_approval(self, run_id: str, approval_id: str, choice: str) -> ControlResult: ...
def respond_clarify(self, run_id: str, clarify_id: str, response: str) -> ControlResult: ...
def queue_message(self, run_id: str, message: str, *, mode: str = "queue") -> ControlResult: ...
def update_goal(
self,
session_id: str,
action: Literal["set", "pause", "resume", "clear", "status", "edit"],
text: str | None = None,
) -> ControlResult: ...
queue_message is named for the legacy queued-message payload shape: it accepts
follow-up chat text rather than arbitrary runtime input. The method name does not
require a new HTTP route. Today /queue is primarily browser-side queue/drain
behavior; the adapter method enters the protocol so a later queue/continue slice
has a typed control surface, but route wiring remains deliberately staged until
the exact legacy entry point and ordering/idempotency contract are explicit.
For update_goal, the action argument is the bounded adapter capability label.
During the legacy-journal slice, the legacy goal parser still receives the full
text payload and remains authoritative for details such as the body of
set <goal text>. Future slices must not route goal semantics from action
alone; doing so would drop the goal body and change /api/goal behavior.
Required data classes / payload fields:
| Type | Required fields | Notes |
|---|---|---|
StartRunRequest |
session_id, message, attachments, workspace, profile, provider, model, toolsets, source, metadata |
Mirrors current /api/chat/start inputs without introducing new behavior. |
RunStartResult |
run_id, session_id, stream_id, status, started_at, cursor, active_controls |
stream_id may remain the legacy stream id during Slice 2. |
RunStatus |
run_id, session_id, status, last_event_id, terminal_state, active_controls, pending_approval_id, pending_clarify_id |
Backed by live legacy state plus journal/session metadata. |
RunEventStream |
ordered events matching Artifact 1, resumable from cursor | Can be implemented by existing SSE + journal replay at first. |
ControlResult |
accepted, status, event_id, safe_message, optional internal payload |
Controls may still call existing handlers in Slice 2. Public HTTP responses must not leak adapter-only fields unless a later RFC expands them. |
The interface is intentionally narrower than a runner. It does not own AIAgent,
tool execution, callback queues, cancellation flags, approval callbacks, or
clarify callbacks in Slice 2. Those remain in the legacy path until their
individual migration slices.
Slice 2 should be guarded by one WebUI-local setting/environment flag, for
example ARES_WEBUI_RUNTIME_ADAPTER=legacy-journal with default
legacy-direct until the seam is proven. The flag selects only the route/adapter
entry point:
legacy-direct -> current /api/chat/start and /api/chat/stream path
legacy-journal -> RuntimeAdapter facade over the same legacy execution path + journal
Reverting must be operationally boring:
legacy-direct,The PR that introduces the seam should include a source-level regression that
the default path remains legacy-direct and that the adapter flag is the only
way the new entry point is selected.
| Adapter method | Slice 2 backend | Explicit non-goal |
|---|---|---|
start_run |
call the existing chat-start preparation and legacy _run_agent_streaming path |
do not move AIAgent construction or thread ownership |
observe_run |
combine existing live SSE fan-out with journal replay cursor semantics | do not build a second renderer or event protocol |
get_run |
derive status from session metadata, live stream presence, and journal terminal state | do not make STREAMS authoritative for durable run existence |
cancel_run |
delegate to existing cancel handler/control path | do not redesign cancellation semantics yet |
respond_approval |
delegate to existing approval response path | do not persist approval callbacks in the main server as a new adapter-owned queue |
respond_clarify |
delegate to existing clarify response path | do not persist clarify callbacks in the main server as a new adapter-owned queue |
queue_message |
delegate to existing queue/continue path when that slice is accepted | do not invent a parallel continuation buffer or run scheduler |
update_goal |
delegate to existing goal command/control path when that slice is accepted | do not move goal evaluation or continuation ownership into the adapter |
Any implementation that needs a new long-lived queue, agent cache, cancellation registry, or callback registry inside the main WebUI process is out of scope for Slice 2 and should become a spec amendment before code lands.
Before the seam is enabled by default, tests should prove at least:
RuntimeAdapter interface exists and all methods are implemented by the
LegacyJournalRuntimeAdapter;start_run returns the same browser-facing stream_id / session_id shape as
/api/chat/start for a synthetic request;observe_run(..., cursor=...) preserves the existing journal replay ordering
and duplicate-prevention behavior;get_run distinguishes live stream, completed, failed, cancelled, and stale /
interrupted states using current live state plus journal/session metadata;cancel_run delegates to the current cancellation path and still emits one
terminal result;These tests are adapter-seam tests, not runner-survives-restart tests. The execution-survives-WebUI-restart gate remains deferred to Slice 4.
Status as of 2026-05-18: Slice 3a cancel routing shipped in v0.51.86 via #2479, Slice 3b approval/clarify routing shipped in v0.51.89 via #2496 / #2507, and the Slice 3c queue/continue + goal gate shipped in v0.51.90 via #2509. Cancel was the smallest control-plane migration because it already had one clear browser affordance, one active-run target, and an existing legacy handler to delegate to. Approval and clarify then proved the same protocol-translator shape for user-mediated callback controls. Queue/continue and goal are the final pre-runner control migration because they can change run lifecycle semantics rather than just resolve an already-pending control.
Scope:
Revert path: per-control feature flags or route-level fallback to legacy control handlers.
The first control migration should route Stop Generation through the
RuntimeAdapter.cancel_run(...) seam while preserving the current legacy cancel
semantics. It should not introduce a new cancellation registry, worker-owned
signal table, or sidecar boundary. During this slice, cancel_run is still a
protocol translator over the existing cancellation path.
Acceptance properties:
ARES_WEBUI_RUNTIME_ADAPTER=legacy-journal, Stop Generation uses
RuntimeAdapter.cancel_run(...); with the default legacy-direct path, the
current route remains available as fallback.CANCEL_FLAGS-like map, cached AIAgent table, long-lived queue, or local
callback registry inside the main WebUI process.ControlResult such as not-active rather than creating extra terminal
events or resurrecting stale stream state.Suggested regression coverage:
cancel_run delegates exactly once and returns a
bounded ControlResult for unsupported/not-active runs;Non-goals for Slice 3a:
/api/chat/start response-shape expansion for adapter-only fields.The next control migration should cover approval and clarify together as one gate, but not necessarily one implementation commit. They are distinct browser widgets, but architecturally they share the same high-risk shape: the agent loop pauses on a live callback, the browser presents a user-mediated decision, and the runtime must resume from a bounded response without orphaning callback state.
During Slice 3b, RuntimeAdapter.respond_approval(...) and
RuntimeAdapter.respond_clarify(...) remain protocol translators over the
existing legacy callback paths. They must not create a second approval queue,
clarify queue, callback registry, pending-prompt table, or runner-owned wait loop
inside the main WebUI process.
Acceptance properties:
ControlResult such as not-active / expired / unsupported; it must not
block the request, recreate a callback, or synthesize a success path.Suggested regression coverage:
respond_approval and respond_clarify delegate
exactly once, return accepted/not-active/unsupported ControlResult values, and
never expose unsafe internal strings to the browser response;Non-goals for Slice 3b:
The next control migration should specify queue/continue and goal before any code
routes those actions through RuntimeAdapter. They may ship as separate
implementation PRs, but they should share one gate because both affect what the
agent does after the current user turn instead of merely resolving a pending
prompt. Queue/continue controls append or schedule follow-up input against live
or resumable work; goal controls set, pause, resume, clear, or inspect a
standing cross-turn objective. Both can accidentally create a second continuation
model if WebUI buffers or evaluates them independently.
During Slice 3c, RuntimeAdapter.queue_message(...) and
RuntimeAdapter.update_goal(...) should remain protocol translators over the
existing legacy queue/goal paths. They must not create a WebUI-owned run queue,
goal evaluator, continuation scheduler, agent loop, or sidecar substitute inside
the main WebUI process.
RuntimeAdapter.update_goal(...) controls goal state mutations only. Post-turn
goal evaluation and the decision to continue remain in the existing agent
conversation loop until the later runner/sidecar slice moves execution
ownership; Slice 3c must not move that evaluator into WebUI or the adapter.
Acceptance properties:
/queue
and /goal semantics, browser status affordances, paused/resumed states, and
post-turn continuation behavior remain unchanged for users. The adapter flag
changes only the route/control entry point.ControlResult states such as not-active,
unsupported, or conflict; they must not create a phantom run, resurrect a
dead stream, or silently enqueue work against the wrong session.Suggested regression coverage:
queue_message and update_goal delegate exactly
once, return accepted/not-active/unsupported/conflict ControlResult values,
and do not expose unsafe internal strings to browser responses;Non-goals for Slice 3c:
AIAgent construction, post-turn goal evaluation, or the
agent continuation loop out of the legacy path;/goal command semantics, queue ordering semantics, or supported
capability metadata;Slice 4 is the first gate that may move active execution ownership out of the main WebUI request process. It should start as a docs/test contract PR before any runner code lands. Slice 1’s journal/replay layer has shipped and passed active validation, Slice 2’s default-off adapter seam has shipped, and Slice 3’s cancel/approval/clarify/goal control routing has proven the protocol-translator pattern. Queue remains staged unless maintainers explicitly ask for a separate pre-runner queue route.
The Slice 4 implementation must not make the adapter a new runtime surrogate. The runner boundary may own active execution, process supervision, run lifecycle, and callback state, but those responsibilities must be centralized behind the adapter/runner contract rather than recreated as scattered globals in the main WebUI server.
Scope:
/v1/runs backends can be evaluated
behind the adapter.Revert path: disable runner backend and fall back to journaled legacy backend.
Before runner code lands, define a narrow contract that covers:
legacy-direct and
legacy-journal paths remain available. Any new runner backend is
feature-flagged, default-off, and revertible by switching the adapter mode back
to legacy-journal without deleting sessions or journal files.AIAgent construction/reuse, active run execution, cancellation flags,
approval/clarify callback wait state, and post-turn continuation evaluation
for runs assigned to that backend.RuntimeAdapter.observe_run(...), get_run(...), and the journal cursor. A
WebUI restart must not be required for the runner to finish writing ordered
events and terminal state.ares-webui.service, reload the session, rediscover the active or terminal
runner-owned run, replay/catch up from cursor without duplicate transcript /
tool / reasoning state, and preserve cancel if the run is still active.ControlResult
states instead of silently falling back to stale in-process state.Suggested contract tests before implementation:
get_run and replay recover the same terminal state;ControlResult values and do not fall back to legacy STREAMS /
CANCEL_FLAGS state;os.environ in the main WebUI process.Non-goals for Slice 4a:
/v1/runs before WebUI can validate the
local runner boundary.Status as of 2026-05-20: shipped in v0.51.94 via #2599.
The first code slice after the Slice 4a contract should be a small
RunnerRuntimeAdapter facade that delegates to an injected runner client. This
is still not the runner process itself. Its job is to pin the adapter-facing
normalization rules before route wiring or process supervision lands:
start_run forwards a StartRunRequest carrying explicit session, profile,
workspace, attachments, model/provider, toolset, source, and metadata payloads;observe_run and get_run normalize runner responses into RunEventStream
and RunStatus so a recreated WebUI server can observe the same runner-owned
state without relying on process-local STREAMS;ControlResult values;AIAgent, worker thread, cancellation registry,
approval queue, clarify queue, goal scheduler, or server-side queue.The implementation remains default-off until a later slice adds an actual runner client/backend and explicit route selection.
Status as of 2026-05-21: shipped in v0.51.105 via #2696. The code adds a
default-off runner-local adapter selection point plus factory wiring for an
injected runner client, while keeping live browser chat routes on the legacy
backend. The restart/reattach harness remains synthetic/fake-runner based until a
later slice introduces a supervised runner process.
After the facade exists, the next narrow implementation slice should add a real runner-client/backend selection point and a synthetic restart/reattach harness, without routing normal browser chat to that backend yet.
Scope:
ARES_WEBUI_RUNTIME_ADAPTER=runner-local, while preserving legacy-direct
and legacy-journal as the default/revert paths;StartRunRequest carries explicit session, profile, workspace,
attachments, provider/model, toolset, source, and metadata fields into the
runner boundary without relying on WebUI process-global environment mutation;ControlResult values, with unsupported controls
returning unsupported / not-active rather than falling back to stale legacy
STREAMS or callback queues;/api/chat/start path on the legacy backend until the runner
backend has a passing restart/reattach harness and maintainer approval to wire
route selection.Acceptance tests for Slice 4c:
legacy-direct remains the default; runner-local
or any later runner mode is selected only by an explicit feature flag./api/chat/start, cancel, approval, clarify, goal, or status response shapes
while the route remains legacy-backed.ControlResult values without consulting
legacy process-local state.Non-goals for Slice 4c:
Status as of 2026-05-23: shipped in v0.51.108 via #2744. The gate remains a docs/test contract: it defines the default-off route-selection requirements but does not itself route live chat to a runner backend.
After runner-local selection exists, the next reviewable gate should define the
first supervised/local runner backend and the route-selection harness before live
browser chat can use it. This is still a contract/test slice first: no default-on
runner mode, no removal of legacy-direct or legacy-journal, and no claim that
production WebUI turns survive restart until the harness demonstrates it.
Scope:
/api/chat/start without changing the
public response shape while the default remains legacy-backed;ControlResult responses, with unsupported controls bounded and
visible rather than silently falling back to legacy process-local callbacks;Acceptance tests for Slice 4d:
ARES_WEBUI_RUNTIME_ADAPTER and
legacy-direct keep /api/chat/start on the existing path; runner-local
is the only mode allowed to select the runner route.Non-goals for Slice 4d:
Status as of 2026-05-24: shipped in v0.51.129 via #2794. The route-selection
harness now makes adapter mode selection explicit: legacy-direct remains the
default, legacy-journal still delegates to the existing journaled legacy path,
and runner-local returns a bounded not-configured response instead of silently
starting an in-process legacy run.
The first implementation after the Slice 4d gate should wire the
/api/chat/start selection point to the existing RuntimeAdapter factory
without adding a supervised runner process yet. The harness must make the
selection behavior explicit: legacy-direct stays default, legacy-journal
continues to delegate to the legacy in-process stream path, and runner-local
does not silently fall back to legacy when no runner client is configured.
Scope:
/api/chat/start through build_runtime_adapter(...) when an adapter
mode is explicitly selected;stream_id, session_id, pending_started_at, turn_id, title,
and effective model/provider metadata;runner-local until a supervised
runner client/backend lands;StartRunRequest payload fields across the seam.Acceptance tests for Slice 4e:
/api/chat/start
keeps using _start_chat_stream_for_session(...) directly.runner-local is selected but
no runner client exists, the route returns a bounded error instead of starting
a WebUI-owned legacy run behind the operator’s back.run_id, status, and
active_controls remain internal until a later contract explicitly exposes
them.Non-goals for Slice 4e:
legacy-direct or legacy-journal;Status as of 2026-05-31: shipped in v0.51.188 via #3073 / #3274. The client
transport is now implemented behind ARES_WEBUI_RUNNER_BASE_URL and remains
default-off. With no endpoint configured, runner-local still returns the
bounded not-configured path and the live in-process _run_agent_streaming path
is unchanged. When configured, WebUI uses a JSON HTTP client boundary for start /
observe / status / controls and bridges observed runner events through the
existing SSE stream route rather than adding main-process runner-owned maps.
The release added two security hardening checks while absorbing #3073:
HttpRunnerClient rejects non-http(s) base URL schemes and uses an opener that
does not follow redirects, so a misconfigured or compromised runner cannot leak a
Bearer token to a redirected host. The release gate reported full pytest passing
and independent default-off/inert-path review.
This bridge is intentionally a WebUI consumer transport seam: the configured
runner must emit events that are already compatible with the browser SSE event
names/payloads, or a later runner-owned normalization layer must translate
Ares runtime families such as token.delta, tool.started, and done before
they reach this route.
After the configured runner-client boundary ships, the next reviewable step is
not to make runner-local the default. It is to define the first supervised
runner process harness that can actually own AIAgent execution behind that
client boundary and prove restart/reattach with a real local runner, not just a
configured external endpoint or fake-runner fixture.
This slice was the client-boundary implementation gate. The goal was to pin the
minimum runner client behavior so the implementation could not become a renamed
STREAMS / CANCEL_FLAGS / cached AIAgent surrogate inside api/routes.py.
Scope:
start_run
spawns or hands off work, how the child is supervised, and how terminal state
is recorded without a main-process active-run dictionary;STREAMS entries;ControlResult
values;Acceptance tests for Slice 4f:
legacy-journal behavior stay unchanged; runner-local uses the supervised
runner client only when the backend is explicitly configured.run_id, status, or active_controls.unsupported, not-active, or conflict results; they must not fall back to
legacy callback queues for a runner-owned run.Non-goals for Slice 4f:
After #3073 / #3274, WebUI has an explicit configured-runner HTTP client and SSE
consumer bridge, but it still does not ship the supervised runner process itself.
The next gate should define the smallest local runner harness that can own
AIAgent execution outside the main WebUI request process while being consumed
through the already-shipped runner-local client boundary.
Scope:
ARES_WEBUI_RUNNER_BASE_URL, not the owner of
process-local runner execution state;ControlResult
values.Acceptance tests for Slice 4g:
ares-webui, owns
AIAgent construction/reuse and active run execution for runner-local runs.runner-local
run, restart only ares-webui, reload the session, rediscover the active or
terminal runner-owned run, replay/catch up from cursor without duplicate
transcript/tool/reasoning state, and preserve cancel if still active.ARES_WEBUI_RUNNER_BASE_URL or switch
the adapter mode back to legacy and the existing in-process path remains
available without session or journal migration.Non-goals for Slice 4g:
legacy-direct or legacy-journal;/v1/runs, this local runner remains a replaceable backend behind
the same adapter/client boundary.The first meaningful milestones are deliberately split.
This gate belongs to Slice 1. It does not prove active execution survives a WebUI process restart, because execution is still owned by the WebUI process in this slice.
It proves:
done, error, or cancelled state replays without duplicate
transcript content.This stronger gate belongs to the runner/sidecar or external-runtime slice, not Slice 1. It proves execution ownership has actually moved out of the main WebUI request process:
ares-webui.If this works without moving runtime ownership into a new pile of process-local globals, the architecture is moving in the right direction.
/v1/runs API, which adapter fields map
directly and which remain WebUI presentation concerns?