Companion ②: We implement the ReAct streaming loop ourselves in exchange for full control over event types, state filtering, and IDE integration. For usage, see User Guide §12.
Series Position
For the full introduction, see Series Introduction and Product Positioning.
| Order | Article | Topic |
|---|---|---|
| 0 | Series Introduction and Product Positioning | Series introduction and product positioning |
| 1–3 | Data Analysis Workflow in the Agent Era, Governable Agent Memory, Prompt and Context Engineering | Workflow, Context |
| 4 | This article | Agent runtime |
| 5 | Unified Query Layer | Query layer |
1. Why Build Our Own ReAct
ReAct (Reasoning + Acting, the reasoning–acting loop) lets the model alternate between "think → call a tool → read the observation → think again" within a single conversation turn. General-purpose Agent frameworks are good at orchestration, but Lantide Data needs finer control:
- SSE event types broken down finely: thought, tool_call, tool_result, message, ask_user, error…
- State-dependent tool allowlist and mid-turn refresh
- UICommand synchronized with the frontend interceptor to open tabs, write SQL, and complete Source Run
- IDE-integrated execution: tool output must land in real SQL tabs, the
TabCacheRegistry, and UICommands—rather than merely returning charts from a remote sandbox - Product-level strategies such as exploration budget, provider fallback, and token compaction
Therefore we adopt a Python AsyncGenerator + our own stream_react_run, rather than applying an off-the-shelf Agent abstraction—we want an execution loop that is line-by-line aligned with IDE state, SSE events, and the tool allowlist, not the convenience of general-purpose orchestration.
2. Architecture Overview
flowchart TB
api[FastAPI_stream]
runner[LantideDataAgent.stream_react_run]
router[StateRouter]
prompt[_build_system_prompt]
loop[ReAct_iteration]
llm[LLM_streaming]
tools[tool_ask_user_UICommand]
sse[SSE_events]
api --> runner
runner --> router
runner --> prompt
runner --> loop
loop --> llm
llm --> tools
tools --> loop
loop --> sse
Each iteration: resolve state → filter tool schemas → assemble the system prompt → LLM stream → execute a tool / pause for ask_user / push a UICommand; after a state-transition tool runs, the prompt and tool set are refreshed within the same turn.
3. Tool Registration and TOOL_AVAILABILITY_MATRIX
Each tool defines its Safety (safe / write) and the AgentState in which it is available. The matrix filters schemas at the start of each iteration, for example:
| State | Typical difference |
|---|---|
| NoProject / Quick | Exploration tools + focus_project |
| ProjectFocused | open_plan, open_report, conditional HTML tools |
| PlanPlanning | Document and exploration; limited run_query for data probing allowed; no SQL Tab tool group, no statistical run_* (the prompt forbids activate_analysis) |
| PlanExecuting | run_sql_tab, source_run_sql_tab, add_report… |
| ProjectFocused / PlanPlanning / PlanExecuting | read_reference, patch_reference (read mappings on demand, make small edits to a Reference) |
The state prompt does not list the tool catalog — names and parameters are governed by the current turn's function schemas (see Prompt and Context Engineering).
4. ReAct Loop Details
4.1 Iteration Limit
max_iterations defaults to 100, preventing an abnormal loop from exhausting resources; when the limit is reached, an iteration_limit event is emitted.
4.2 Exploration Budget
show_tables, read_schema, and validate_query count toward exploration. After exceeding the exploration_soft_cap (default 7), the tool call is skipped and EXPLORATION_BUDGET_REACHED is returned.
Continuation protocol: the next tool call must be ask_user, the question must contain the marker [[EXPLORATION_BUDGET]], and the options must include Continue exploring / Stop for now. If the user chooses to continue, the exploration count for this turn is reset; if the user chooses to stop, the Agent should first consolidate its findings before deciding. It should not bypass human confirmation by silently injecting a system string.
4.3 Auto-continuation
When finish_reason == "length" and there is still room, "Please continue..." may be appended (up to a limit), to avoid truncating long reports.
4.4 Mid-turn State Transition
After focus_project, add_report, etc. run, the state is re-resolved from the session, and messages[0] system prompt and the tool set are updated, so that subsequent iterations in the same turn land in the correct state (e.g., NoProject → ProjectFocused).
5. Provider Fallback
When an LLM call fails, it can fall back to a backup profile per configuration and be marked as such in the SSE, reducing the chance that a single unavailable model fails an entire turn.
6. System Prompt Assembly and Mid-turn Refresh
LantideDataAgent._build_system_prompt() merges core, state, knowledge, catalog hints, and the conditional HTML section. After a state transition or an html_report_active flip, it is rebuilt within the same turn, aligned with the tool schemas.
6.1 Query Step Ledger Injection (Layer 3.5)
During multi-step analysis, the model needs to remember "which table the previous step materialized, and what the SQL looked like." Each conversation maintains a Query Step Ledger within the workspace: after a run_query / run_sql_tab succeeds or fails, a step index is written; the system prompt appends a condensed summary (cache name + SQL preview, ≤1500 chars), so that the model still knows recent steps after many turns. The full SQL is looked up via query_result, get_cache_source, or the registry.
Division of labor with Plan Progress: the Steps UI during Plan execution and the execution_record.steps of read_plan come from the steps[] in *.progress.json (which becomes steps_frozen after the report); the Ledger still serves conversation continuity. fork_conversation copies the source conversation's ledger in full to the new conversation, so that list_query_steps and the summary are not broken after a fork.
6.2 Ledger-related Tools
| Tool / API | When to use | Purpose |
|---|---|---|
get_cache_source |
Need to review the original SQL of a cache table | Equivalent to the UI "View SQL," same source as GET /cache/{tab_id}/source |
list_query_steps |
A multi-step chain is out of order or step_ids need verification | Lists the steps of this conversation; optionally with truncated SQL |
query_result |
Read the result and SQL of a given run | Meta sidecar in memory or after spill |
[[QUERY_STEP]] protocol |
After each successful run (assistant body) | A one-line summary; after the runner parses it, purpose / pitfalls are backfilled |
conversation_id is injected by the runner; the LLM may not pass it itself; when there is no conversation, the ledger is skipped and the materialization strategy still runs on the workspace heuristics.
Annotations do not go through the Query Step Ledger—their state lives in the *.annotations.json sidecar and the body mark, handled by read_plan / read_report / resolve_annotations (see Prompt and Context Engineering §11).
6.3 Project Document and Annotation Tools
| Tool | Safety | Typical state | Purpose |
|---|---|---|---|
read_plan / read_report |
safe | Plan/Report related | Disk body + sidecar + annotation_summary |
resolve_annotations |
write | Planning (body lock is not Executing/Executed) | Atomically patch the body and sidecar per open annotations |
read_reference |
safe | ProjectFocused, PlanPlanning, PlanExecuting | Load the full text of a Reference per the [Ref: …] When-to-read |
patch_reference |
write | Same as above | Small-scope find-replace; does not create a new Reference file |
7. HTML Tools and Contract (Body Summary)
For the product motivation, see Data Analysis Workflow in the Agent Era §5. For usage, see §10.
7.1 Conditional Registration html_report_active
When true (task_mode=html_report or a Report tab with contract HTML already on disk):
- The always-present
create_html_report(Generate / Re-generate) - Additions such as read_html_text, get/patch_html_chunk_by_id, get/patch_html_styles
- Injection of the Editing HTML Reports state section
After a successful create_html_report in the same turn, the next iteration recomputes the flag and schemas, with no need to switch tabs.
7.2 activate_html_editing
When the user is not in a Report tab but wants to edit HTML, the Agent first calls this tool to unlock the chunk/style tools, then read → get → patch. When multiple reports all have HTML and no filename is specified, it should ask a follow-up or return AMBIGUOUS_REPORT. Old files without data-report-contract="1" should be Re-generated. Each conversation must re-activate or return to the Report tab.
7.3 Contract Highlights (Internalized Narrative)
- Root element:
<html data-report-contract="1" data-report-profile="standard|standard-chartjs|revealjs|offline"> - Inside
<head>, exactly one<style id="report-styles">carries the global CSS - Directly under
<body>, patchable blocks:data-text-id(regex^[a-z][a-z0-9_-]{0,63}$) +data-chunk-desc; nested patchable containers are forbidden - create_html_report performs full validation; chunk tools validate only the body
- standard-chartjs: the head includes the Chart.js CDN +
#report-chart-init; charts usecanvas[data-chart-spec]JSON; after patching, you must Open Report again to redraw
8. Agent Memory Tools
| Tool | Direction | Description |
|---|---|---|
expand_knowledge_catalog |
Read | In catalog mode, batch-loads full Rules/Info entries by parent tag |
propose_knowledge |
Write to queue | Proactively proposes reusable knowledge into Queued Knowledge; injected only after the user Applies it |
propose_knowledge shares knowledge_queue.json with passive extraction; the runner injects evidence_corpus_texts for validation. Rate limits: at most 2 proposals per user message (per full Agent stream); at most 5 pending per conversation (released after Apply/Dismiss). evidence_quote must be a verifiable substring of a recent user/tool message; SQL, cache table names, and other execution-state content are forbidden. For details, see Governable Agent Memory §4.3.
9. UICommand and Frontend Interceptor
Some tools do not end by directly returning a string, but instead send a UICommand SSE: the frontend handleUICommand executes open_tab, update_tab_content, set_tab_results, source_run_complete, focus_project, etc., and dedupes with processedEventIds. The user sees the tabs and results change, forming a "copilot" experience. See §12.6.
10. ask_user: Pause/Resume
- The Agent calls
ask_user - SSE
type: ask_user→ the frontend interaction area - The user replies →
POST /api_v1/ask_user_response - The result is fed back into ReAct, and iteration continues
The timeout is about 5 minutes. Exploration quota, execution mode, Execute confirmation, and so on all follow this protocol (Data Analysis Workflow in the Agent Era).
11. Token Compaction Strategies
Three strategies complement each other on different time scales, and can be triggered in sequence within the same ReAct turn:
- History truncation (cross-turn): long conversations preserve the analytical context and discard earlier irrelevant messages (Prompt and Context Engineering §12).
- Mid-turn compaction (within a single turn): when tokens approach the limit after many tools in one turn, this turn's tool results are compacted, preserving key fields such as
step_idand cache names (same article, §13). - Auto-continuation: when the model output is truncated due to
length, a continuation request is appended while there is still headroom (§4.3); works in tandem with context window discovery.
12. Frontend Streaming (Optional)
streamStateMachine dispatches the SSE into Segments such as thought / tool_call / tool_result / message, and flushes with a mutable draft + requestAnimationFrame throttling, avoiding a React re-render on every token; the finish/error/abort paths forceFlush.
13. SQL Execution Tools (Aligned with the Unified Query Layer)
| Tool | Function |
|---|---|
run_sql_tab |
Executes a persistent tab and materializes a cache |
source_run_sql_tab |
Source Run DAG |
sql_tab_result |
Reads a tab's cached result (supports resolution by tab title) |
validate_query |
Exploration: validate only |
run_query |
Preview rows (≤200); conditionally materializes into an agent cache (see Unified Query Layer §8.1) |
The quality signals returned by tools also feed into the runner's judgment: complexity_hint means the step needs to be split smaller; optimization_hint is only a suggestion to consider cache and does not block analysis. The Release smoke's hard gate focuses on plan → todo → report, the absence of obvious error loops, and report quality signals; DAG / Source Run are soft signals, used to observe whether the model naturally uses the traceability capability.
14. Engineering Decision Overview
| Decision | Reason |
|---|---|
| Build our own ReAct | SSE + state + UICommand |
| Exploration ask_user continuation | Human confirmation rather than a hard stop |
| Conditional HTML tools | Reduce default schema noise |
| max_iterations 100 | Long-chain analysis can complete |
| UICommand | Real IDE side effects |
| Release gate weights analysis quality | Do not let cache shape replace a credible report |
15. Conclusion
The Agent architecture is the execution engine for the workflow and Context: the ReAct loop turns the layered prompt into SSE events, tool calls, and IDE side effects (UICommand). The difference between the Harness and a chat sandbox is that execution is bound to a real workspace.
If the model "keeps validating but never runs run_query," check both the QEM explanation in Prompt and Context Engineering and the exploration budget in this article; for query materialization and lineage semantics, see Unified Query Layer.