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:backend/app/services/agent/builder.py: agent factory, system prompt, dynamic promptbackend/app/routers/query.py: SSE streaming endpoint, turn counter, snapshot capturebackend/app/tools/:_decorator.py,_context.py,manager.py,documents.py,todos.py,preferences.py,charts.py,artifacts.pybackend/app/db/models.py: ORM models incl.ArtifactandWorkspaceSnapshotbackend/app/db/repositories/workspace_repository.py: DB layer for documents / todos / prefs / artifacts / snapshotsbackend/app/routers/workspace.py: REST endpoints for the human side and for past artifacts/snapshotsfrontend/src/app/app/page.tsx: workspace shell + SSE handlerfrontend/src/components/app/artifact-panel.tsx: centre panelfrontend/src/components/app/artifacts/registry.tsx:artifact_type → React componentregistryfrontend/src/lib/api.ts:streamQuery+ workspace REST client
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
- ASCII
- Diagram
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:
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):
- ASCII
- Diagram
-
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. -
For replay / audit, we use snapshots instead. Before each user turn the router writes a
workspace_snapshotsrow 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 stateI), the per-turn workspace injection, and these snapshots are what constitute the agent’s “memory”. -
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_artifactif it needs to reason over the data.
3.1 The system prompt: two phases + HAI-Co² rules
The full text lives inHAICO_SYSTEM_PROMPT in builder.py. Its structure is summarised below; expand the panel beneath the table to read the prompt verbatim. Structure:
The full HAICO_SYSTEM_PROMPT (click to expand)
The full HAICO_SYSTEM_PROMPT (click to expand)
On preference locking: the prompt text itself does not mentionupdate_preferencesor 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:
(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:
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:
4. The per-turn lifecycle
- ASCII
- Diagram
- ASCII
- Diagram
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 tocreate_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
- ASCII
- Diagram
*Tools class with @workspace_tool(...) and collect_tools finds it automatically.
Adding a new domain (e.g. citations, code execution, graphs):
- Create
backend/app/tools/<domain>.pywith aclass XToolsthat holdsself.thread_idand one@workspace_toolmethod per LLM-facing op. - Register an instance in
tools/manager.pyby adding it to the list returned by_collections_for.
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):
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.
- ASCII
- Diagram
5.5 The per-turn contextvar
Tools that produce artifacts need to know which turn they belong to. The query router publishes the currentturn_index via a ContextVar before invoking agent.astream:
backend/app/tools/_context.py:
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 (NULLif 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.
- ASCII
- Diagram
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 withturn_index <= k. Snapshots are never copied; the graph endpoint resolves inherited history through theconversation_brancheslineage instead. - Turn numbering continues: the branch’s
conversation_users.turn_indexstarts 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)-thHumanMessage). No LLM client is constructed for this; a minimalStateGraph(MessagesState)bound to the sameAsyncPostgresSaverdoes the read and the write (verified bybackend/scripts/spike_branch_seeding.pyandbackend/tests/test_branch_seeding_integration.py). - Failure handling: branch creation is 3-phase (SQL rows with
status='pending', then checkpointer seed, thenstatus='active'); on failure a compensating cleanup removes all SQL rows for the new thread and best-effort deletes its checkpointer thread. Onlyactivebranches 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:
_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:- ASCII
- Diagram
app/page.tsx onSend:
- Before opening the SSE: call
flushDocument()so the dynamic prompt reads the latest user edits, not a stale autosave. - 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.
- While streaming: render
tool_callevents into the Internal Reasoning trace using only theaction_and_reasoningtext. Eachartifactevent eagerly inserts a row intoartifactsand switches the centre panel to render it via the registry. Each successful tool-resultstepalso schedules a debouncedrefreshPlanning(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. - After
complete: cancel any pendingrefreshPlanning, then callflushPending(tid)andrefreshWorkspace(tid)to reconcile against the canonical server state (replaces the eager artifact rows with their persisted 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:
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:
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:
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 inartifacts.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).
11. Where to read next
- 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_toolconventions 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