Architecture
How the HAI-Co² system is put together: the runtime topology, the data stores, the per-thread tool-composition model, how the codebase maps onto the HAI-Co² formalism, and the load-bearing decisions behind all of it, each linked to the ADR that records why. This file is the structure and decisions map. For how the agent actually runs a single turn (the per-turn context, the ReAct loop, the model-node retry wrapper, the SSE stream, snapshots, and typed artifacts), see agent-core-logic.md.Runtime topology
- ASCII
- Diagram
frontend/src/app/app/page.tsx)
with four panels: Preferences (top-left), the Planning panel (Objective + plan,
lower-left), the centre document/artifact panel, and the chat with the
co-construction trajectory map above it. The side panels talk to the backend
over plain REST; the chat opens an SSE stream to the agent. The wiring of every
panel and event is detailed in agent-core-logic.md.
The per-thread tool set
build_tools(thread_id) (in backend/app/tools/manager.py)
is not a registry. It composes the per-domain *Tools collections
(DocumentTools, ObjectiveTools, TodoTools, PreferenceTools, ChartTools,
ArtifactTools), all bound to the same thread_id, and flattens their
@workspace_tool-decorated methods into one List[StructuredTool] for the
React agent (15 tools today). Adding a tool is a method on an existing *Tools
class; adding a domain is a new class appended in _collections_for. That is
the only wiring change. The decorator (@workspace_tool) handles the rest:
schema extension, a fresh DB session per call, artifact persistence, and the
JSON envelope. See agent-core-logic.md §5 and
tools.md.
Persistent state
Postgres holds both the HAI-Co² shared workspace and the agent’s memory:
Alongside these, the LangGraph checkpointer tables store the full per-thread
message history (the HAI-Co² informational state
I). They are LangGraph’s own
schema, created idempotently (CREATE TABLE IF NOT EXISTS) by
checkpointer.setup() in init_checkpointer.py
at container startup. The agent is otherwise stateless per request:
build_agent() is rebuilt every turn, but its memory persists because both the
checkpointer and the workspace tables are keyed by thread_id. The full schema
(after migration 0007) is documented in database-schema.md.
Key flows
1. User asks a question (/api/query/stream_steps/sse)
- Request authenticated via JWT.
thread_idvalidated (must belong to the user), or created on first message.- A workspace snapshot is captured and
turn_indexbumped BEFORE the agent runs (_capture_snapshotwrites one immutableworkspace_snapshotsrow recording the objective, document, prefs, todos, and user message the agent will see this turn). build_agent()is called with the request’s model config. The system prompt is rebuilt dynamically each turn by an async callable that injects the current<workspace>(objective, preferences, todos, document, latest-artifact pointer); thisSystemMessageis recomputed, never persisted to the checkpointer. The tool-bound model is wrapped so blank / malformed / truncated generations are retried inside the model node (see agent-core-logic.md §4.1).- Agent streamed via
agent.astream(..., stream_mode="updates"); each newly produced message becomes an SSEstepevent (frames are deduplicated on a content signature), with: keep-alivecomments emitted during long single generations. - Tool calls are visible in the stream → the frontend can preview what the agent is about to do.
- When a tool’s envelope carries a typed artifact (e.g. a chart), a dedicated SSE
artifactevent is emitted so the centre panel updates mid-stream. - A final
completeevent closes the stream (or anerrorevent on failure).
2. Agent edits the shared workspace
Tools registered inapp/tools/ can mutate objectives, documents, todos,
and preferences. After the stream completes, the frontend re-fetches workspace
state (refreshWorkspace) so the side panels reflect the agent’s mutations;
planning panels also refresh mid-stream as tool results land. Typed
artifacts arrive live via SSE artifact events during the stream.
Preferences carry a locked flag. The agent path respects it:
update_preferences / remove_preferences skip any locked preference (reporting
it as skipped rather than erroring). The user path ignores it: the REST
endpoints edit/delete with respect_lock=False, and PATCH /preferences/{id}
accepts a locked field to lock/unlock. The injected <preferences> block marks
locked rows with (locked).
3. Branching & restore (/api/conversations/{thread_id}/branch)
Every completed user turn is already an immutable “commit”, the
workspace_snapshots row keyed by (thread_id, turn_index) captured before the
agent ran. A conversation forks at any turn k into a new thread_id: the
workspace as of turn k is copied, and the message-history prefix is seeded into
the new checkpointer thread with one aupdate_state call. The choice of “new
thread + seeded state” over per-table branch_ids is recorded in
ADR-0003;
the full mechanics live in conversation-branching.md.
The Studio surfaces this as the co-construction trajectory map above the chat
(GET /api/conversations/{thread_id}/graph assembles the conversation family:
nodes = turns, edges = turn/branch links). Continue from here previews any
step read-only and forks a new path only once the user sends a message there;
per-message retry and edit actions fork the same way (“fork, never
destroy”), so earlier states are never overwritten.
Mapping to HAI-Co²
Key decisions (the ADRs)
The structural choices above are recorded as Architecture Decision Records; the ADR is the source of truth for why. This doc describes what is; when the two disagree, the newer ADR wins and this file should be updated.
A few decisions are not (yet) their own ADR but shape the codebase; they are
documented in the agent deep-dive instead:
- Dynamic, non-persisted system prompt: the workspace is re-injected every turn rather than written into checkpointer state, trading recomputation for bounded history. See agent-core-logic.md §3.
- Snapshots, not persisted prompts, for replay: one immutable
workspace_snapshotsrow per turn makes any past context deterministically reconstructable. See agent-core-logic.md §6. - Typed-artifact channel: the agent emits typed JSON payloads
(
{artifact_type, payload}), never SVG or React code; the frontend owns rendering. See agent-core-logic.md §10.4. - Model-node reliability wrapper: blank / malformed / truncated provider turns are retried (or warned on) inside the model node, transparently to the SSE stream. See agent-core-logic.md §4.1.