Skip to main content

Agent Core Logic

A deep tour of how the HAI-Co² backend agent thinks: what it sees on every turn, how the shared workspace and typed artifacts reach it, how its tools are wired, and how its output flows back to the frontend. Files referenced throughout:

1. The four shared surfaces

HAI-Co² is the conceptual frame: humans and the agent co-construct a single solution by sharing four objects. Each maps to a concrete UI panel and a concrete piece of backend state: The left-lower Planning panel shows the agent’s Objective (its one-line read of the goal, user-editable) above its plan: the numbered, nestable todo steps it ticks off as it works, kept as its own execution ledger. The plan has two views: an interactive tree (drag, check, edit) and a read-only markdown checklist; from the tree the user can add sub-steps and indent / outdent a todo step to change its nesting level (drag only reorders within a level; indent/outdent move a todo step and its subtree, persisted via PATCH /todos/{id}/reindent). The left rail uses fixed proportions (the Planning panel taller than Preferences) and each list scrolls inside its panel, so adding a preference or todo step never resizes or pushes the other panel. All are per-thread: every row is keyed by the LangGraph thread_id that uniquely identifies a conversation. A user owns a thread through the conversation_users link table, which also carries a monotonic turn_index counter (one per user message) used to tag everything the agent produces during the turn. The centre panel is no longer document-only: it renders whatever artifact is currently selected. By default it shows the live, editable document; if a tool produced a typed artifact this turn (a chart, a table, …), the panel auto-switches to render it. See §10.

2. High-level request topology


3. What context the agent receives on every turn

The agent is a LangGraph React agent (create_react_agent), i.e. it loops LLM → maybe tool calls → LLM → … → final answer. It is built fresh for every request by build_agent(checkpointer, thread_id, runtime_config=None) (builder.py), yet its memory persists because both the checkpointer and the workspace tables are keyed by thread_id:
Two of those arguments are not what they look like. model is not get_llm(...) directly: it is a _dynamic_model callable returning the tool-bound model wrapped in a blank / malformed-turn retry (§4.1). And prompt is not the static HAICO_SYSTEM_PROMPT string: the agent’s context is assembled freshly for each user message by that callable installed as the prompt= argument: backend/app/services/agent/builder.py (_build_dynamic_prompt):
That returned list is what the LLM actually sees:
Three important properties:
  1. The system message is NOT persisted to the checkpointer. It is recomputed every turn, so the agent always sees the freshest workspace even if the user edited the document in the centre panel a millisecond ago. Persisting the rendered system message would duplicate the document body on every turn, bloating storage proportional to doc_size × turns.
  2. For replay / audit, we use snapshots instead. Before each user turn the router writes a workspace_snapshots row capturing the document, prefs, todos, and the user message. The system message at any past turn is therefore deterministically reconstructable from the snapshot + the static prompt template. See §6. Together, the LangGraph checkpointer message history (the HAI-Co² informational state I), the per-turn workspace injection, and these snapshots are what constitute the agent’s “memory”.
  3. The objective, preferences, and todos ARE auto-injected in compact form: they are small, always-relevant, and shape every decision the agent makes. The full document is also injected (truncated at 30 000 chars). The latest typed artifact is injected as a pointer only (id + type + title); the actual payload stays out of the LLM context. The agent can fetch the payload with get_artifact if it needs to reason over the data.

3.1 The system prompt: two phases + HAI-Co² rules

The full text lives in HAICO_SYSTEM_PROMPT in builder.py. Its structure is summarised below; expand the panel beneath the table to read the prompt verbatim. Structure:
On preference locking: the prompt text itself does not mention update_preferences or any locking rule. Lock enforcement lives in the tool bodies (preferences.py) and reaches the agent two ways: the (locked) marker rendered into the <workspace> preferences block (see §3.2), and the “locked; ask the user to unlock” message returned when the agent tries to modify or remove a locked item. Only the user can lock/unlock (and always edit) a preference, via the top-left panel / workspace REST.

3.2 Workspace block formats

The <objective> block leads the workspace: it is the agent’s evolving, one-line understanding of the user’s goal. It is co-constructed: the agent infers it from the user’s messages and refines it as the conversation evolves (set_objective writes it), and the user can edit it from the Planning panel. Because the objective is snapshotted per turn, its evolution is recoverable: GET /api/workspace/{tid}/objective/history returns one entry per change (plus the current live value), which the panel surfaces in a small history popover. Until a concrete one is set, the block shows the agent’s standing first objective, phrased as a real goal, not an aside, so “figure out what the user actually wants” is the active objective from turn one:
Hard preferences are listed before soft ones so the agent reads the non-negotiables first:
A (locked) marker is appended to any preference the user has locked: the agent must treat it as read-only (its update_preferences / remove_preferences calls skip and report it with a “locked; ask the user to unlock it first” message), while the user can still edit, delete, or unlock it from the top-left panel. Empty preference / todo lists render as (none recorded yet) and (no plan yet) respectively. The plan is an arbitrarily-nested tree serialised depth-first (pre-order). Each row is prefixed with its dotted outline number (1, 1.1, 1.1.1, 2 …), derived at render time from each todo’s depth, and indented by nesting level. Done todos get an [x] checkbox so the agent can skip re-doing them:
The document block is truncated at ARTIFACT_INJECT_MAX_CHARS = 30_000 characters; the agent can still read the full document via tools, but in practice it rewrites with update_document which works regardless of the snapshot size:
The latest-artifact line is a pointer only, no payload is injected:

4. The per-turn lifecycle

Inside step 6, every iteration of the React loop runs through this cycle:
Important consequence: between two LLM calls inside the same user turn, the dynamic prompt re-runs. A tool that mutates the document (update_document) is visible to the very next LLM call in the same turn; the agent can edit, then re-read the snapshot, then refine.

4.1 Model-node retry: blank, malformed, and truncated turns

The model handed to create_react_agent is not the raw provider client. build_agent wraps the tool-bound model in _model_with_empty_retry and passes it as a dynamic model callable, so LangGraph uses it as-is (tools are bound once, here, not re-bound onto the wrapper). The wrapper re-asks the model inside the model node, before a bad generation becomes the turn’s result, so it is transparent to the SSE stream: only the final attempt is surfaced. Three provider failure modes are recognised (_retry_reason; up to _EMPTY_RESPONSE_MAX_ATTEMPTS = 3 model calls per node): The blank / malformed cases are usually recoverable on a retry (gemini-2.5-flash in particular can return an empty candidate when its whole output-token budget went to internal “thinking”, or a transient safety stop). Truncation is different: retrying under the same cap truncates again, and the dropped field is often a tool call’s largest argument (e.g. update_document.content, which then fails validation downstream as content: Field required), so the wrapper logs an actionable “raise max_tokens” warning instead of retrying. (Issues #144, #145.)

5. The tool system

5.1 Tool registration flow

Adding a new tool to an existing domain is a one-method change: decorate the new coroutine on the *Tools class with @workspace_tool(...) and collect_tools finds it automatically. Adding a new domain (e.g. citations, code execution, graphs):
  1. Create backend/app/tools/<domain>.py with a class XTools that holds self.thread_id and one @workspace_tool method per LLM-facing op.
  2. Register an instance in tools/manager.py by adding it to the list returned by _collections_for.
That is the only wiring change.

5.2 Current tool inventory (15 tools)

Preference locking: update_preferences and remove_preferences first read the prefs, and for any target that is locked they do not mutate; they skip it and report a plain message that the preference is locked and cannot be modified (update_preferences) or removed (remove_preferences) and that the user must unlock it first, not an exception. The repository enforces this via respect_lock=True. The user side never respects the lock: the workspace REST endpoints call the repo with respect_lock=False, and PATCH /preferences/{id} accepts a locked field, so the user can always edit, delete, lock, or unlock. “Locked” means the agent cannot modify or remove it; only the user can. Every tool’s schema is automatically extended with a required action_and_reasoning: str field by the decorator (§5.4).

5.3 The (content, artifact) return convention

The decorator inspects each tool’s return value: Example from chart_generator (charts.py):
The decorator wraps this as a JSON envelope:
Failure envelope:
Trade-off: the artifact payload is stored in the envelope but is not something the LLM is encouraged to read; the system prompt instructs the agent to refer to artifacts by id and avoid re-emitting their data. The real consumer of payload is the frontend renderer.

5.4 The injected action_and_reasoning arg

Every tool’s schema is extended with a required str field action_and_reasoning. The agent must populate it with one short, non-technical sentence on every call. The decorator pops it before invoking the tool body and logs it server-side. The frontend renders it as the only label on each tool-call node in the Internal Reasoning trace; tool names, arguments, and return values never reach the user.
A separate session per tool call avoids contention with the request-level session opened by FastAPI’s dependency injection.

5.5 The per-turn contextvar

Tools that produce artifacts need to know which turn they belong to. The query router publishes the current turn_index via a ContextVar before invoking agent.astream: backend/app/tools/_context.py:
The router does:
Every tool invocation inside that block sees get_turn_index() == N and tags any persisted artifact with that index. ContextVars are coroutine-local, so concurrent SSE streams (different users) can’t interfere with each other. (get_session_context additionally tags the turn’s spans with the thread id for Phoenix’s Sessions view, a no-op when tracing is disabled; _with_heartbeats injects keep-alive comments during long generations; see §7.)

6. Workspace snapshots: how we replay the past

workspace_snapshots is captured once per user turn, BEFORE the agent runs. One row per (thread_id, turn_index) (unique constraint), containing:
  • user_message: the raw user text that triggered the turn,
  • document_title + document_content: the document at the start of the turn,
  • objective_text: the agent’s objective at the start of the turn (NULL if unset),
  • preferences_json: [{title, subtitle, kind, locked}, ...] at the start of the turn,
  • todos_json: [{text, done, position, depth}, ...] at the start of the turn,
  • captured_at: server timestamp.
This makes the agent’s context for any past turn deterministically reconstructable: load the snapshot, render it through the static system prompt template, and you have exactly what the agent saw.
Snapshots are read-only; they are written once and never updated. Even if the user edits prefs/todos after the agent finishes a turn, the snapshot of the next turn captures the new state, preserving an immutable timeline. REST access for the frontend / audit tools:

Branching & restore (issue #55, ADR-0003)

Because a snapshot row behaves like a commit, a conversation can be forked at any completed turn k (POST /api/conversations/{tid}/branch with {"from_turn": k, "reason": "…"}, handled by backend/app/services/branching.py). The full design lives in conversation-branching.md; the essentials:
  • Node semantics. Graph node k = completed turn k. The workspace after turn k is snapshot(k+1) when a later turn exists (it also absorbs manual edits made before turn k+1 started, by design, “the state exactly as the agent saw it next”), else the live workspace rows.
  • What is copied to the new thread: resolved document, preferences (snapshot JSON has no position, so positions are re-assigned by array index), todos, and artifact rows with turn_index <= k. Snapshots are never copied; the graph endpoint resolves inherited history through the conversation_branches lineage instead.
  • Turn numbering continues: the branch’s conversation_users.turn_index starts at k, so its first message becomes turn k+1 and turn numbers stay aligned across sibling branches.
  • Message history is seeded into the new checkpointer thread with one aupdate_state(..., as_node="__start__") call writing the message prefix (everything strictly before the (k+1)-th HumanMessage). No LLM client is constructed for this; a minimal StateGraph(MessagesState) bound to the same AsyncPostgresSaver does the read and the write (verified by backend/scripts/spike_branch_seeding.py and backend/tests/test_branch_seeding_integration.py).
  • Failure handling: branch creation is 3-phase (SQL rows with status='pending', then checkpointer seed, then status='active'); on failure a compensating cleanup removes all SQL rows for the new thread and best-effort deletes its checkpointer thread. Only active branches are visible to readers.
  • The Studio exposes this as Continue from here on each trajectory step. Selecting a step previews it read-only; the branch is created only when the user sends a message from there, so inspecting an earlier step never leaves an empty thread. It never truncates or deletes the original thread.

7. The SSE event stream

backend/app/routers/query.py. Each line on the wire is data: <json>\n\n:
Between events, when no graph update arrives for _HEARTBEAT_SECONDS, the router emits a bare SSE comment line (: keep-alive\n\n) so the connection survives a long single generation. The in-flight step is not cancelled (_with_heartbeats keeps awaiting the same task) and the client parser ignores any line that does not start with data:. (Issue #147.) Step events dedup on (message_id, type, content, tool_calls) (sorted JSON) to avoid emitting the same final assistant message twice when LangGraph yields it across multiple frames. artifact events dedup on artifact_id. The artifact event is what makes the centre panel update mid-stream; without it, the frontend would have to wait for the post-complete refreshWorkspace round-trip to discover the new chart.

8. Frontend reconciliation

The frontend treats the backend as the source of truth. Two mechanisms keep panels in sync:
Critical sequencing in app/page.tsx onSend:
  1. Before opening the SSE: call flushDocument() so the dynamic prompt reads the latest user edits, not a stale autosave.
  2. If the user added prefs/todos before any message existed, eagerly create a thread + flush pending items so the very first agent turn sees them.
  3. While streaming: render tool_call events into the Internal Reasoning trace using only the action_and_reasoning text. Each artifact event eagerly inserts a row into artifacts and switches the centre panel to render it via the registry. Each successful tool-result step also schedules a debounced refreshPlanning(tid) that re-pulls the objective, todos, and preferences, so those panels update mid-stream as the agent’s tool calls land (rather than only after the turn completes). The document and artifacts keep their own update paths.
  4. After complete: cancel any pending refreshPlanning, then call flushPending(tid) and refreshWorkspace(tid) to reconcile against the canonical server state (replaces the eager artifact rows with their persisted copies).
A pending pref/todo before the first message uses a negative id so the UI can distinguish “not yet persisted” from “saved with server id”. Once the thread exists the temp items get swapped for their server copies.

9. End-to-end example: “chart Q1 vs Q2 revenue: 120, 180”


10. Different artifact types and how each is handled

10.1 Centre-panel selection model

The centre panel is generic: it renders whatever artifact the user (or the agent’s most recent turn) has selected. A small dropdown in the panel header lets the user flip between the live document and any past typed artifact. When a new artifact arrives via SSE, the panel auto-selects it so the chart appears instantly.

10.2 The frontend registry

frontend/src/components/app/artifacts/registry.tsx is the single dispatch point:
Charts (line / bar / pie) are rendered by recharts components in chart-views.tsx. Multi-series data is auto-detected; each series gets a colour from a fixed palette matching the brand tokens.

10.3 Adding a new artifact type: the recipe

Goal: a new artifact type the agent can produce, e.g. a sortable <table> that renders structured data the LLM doesn’t need to read. 1. Pick a discriminator string: e.g. "data_table". 2. Add a tool that returns a (content, artifact) tuple in backend/app/tools/tables.py:
3. Register the collection in tools/manager.py: return [..., TableTools(thread_id)]. 4. Mention the new tool in HAICO_SYSTEM_PROMPT’s “Tool guide” section so the agent knows when to use it. 5. Add a frontend renderer in frontend/src/components/app/artifacts/table-view.tsx:
6. Add ONE entry to the registry: REGISTRY.data_table = DataTableView; 7. (Optional) Add an icon mapping in artifact-panel.tsx so the dropdown shows the right glyph for the new type. Nothing else changes. The decorator persists the artifact, the SSE event fires automatically, the ArtifactPanel auto-selects it, and the registry dispatches to your renderer.

10.4 Why typed payloads, not React code or SVG

We deliberately do not let the agent emit React code or SVG markup. The trade-offs: This follows the typed-artifact pattern ({artifact_type, payload}): LLM produces typed data, frontend owns rendering.

10.5 Past-artifact access for the agent

The agent can re-fetch any past artifact via two tools in artifacts.py:
  • list_artifacts(artifact_type?, limit?) returns a compact index: #42 [turn 3] line_chart: Q1 vs Q2.
  • get_artifact(artifact_id) returns the artifact as a JSON string of {id, type, title, turn_index, payload} (the payload plus its metadata, not the bare payload).
Use cases: “compare to the chart from turn 3”, “what did the bar chart say?”, “summarise across all the tables we’ve built”. The system prompt instructs the agent to refer to artifacts by id and describe what changed rather than re-emitting payloads verbatim; this is the whole point of the artifact channel (large data that bypasses the LLM context).
  • architecture.md: the system’s static structure, data stores, tool-composition model, and the decisions behind it (→ ADRs)
  • conversation-branching.md: the turn-as-commit model, branching, restore, and the trajectory graph
  • tools.md: the @workspace_tool conventions cheat-sheet
  • database-schema.md: the full persistent data model after migration 0007
  • adr/: the Architecture Decision Records, the why behind the structure (esp. ADR-0003, ADR-0004)
  • Human-AI Co-Construction Paper/: the formal HAI-Co² model