Skip to main content

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

The frontend is a single workspace shell (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)

  1. Request authenticated via JWT.
  2. thread_id validated (must belong to the user), or created on first message.
  3. A workspace snapshot is captured and turn_index bumped BEFORE the agent runs (_capture_snapshot writes one immutable workspace_snapshots row recording the objective, document, prefs, todos, and user message the agent will see this turn).
  4. 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); this SystemMessage is 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).
  5. Agent streamed via agent.astream(..., stream_mode="updates"); each newly produced message becomes an SSE step event (frames are deduplicated on a content signature), with : keep-alive comments emitted during long single generations.
  6. Tool calls are visible in the stream → the frontend can preview what the agent is about to do.
  7. When a tool’s envelope carries a typed artifact (e.g. a chart), a dedicated SSE artifact event is emitted so the centre panel updates mid-stream.
  8. A final complete event closes the stream (or an error event on failure).

2. Agent edits the shared workspace

Tools registered in app/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_snapshots row 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.