Human-In-The-Loop (HITL) Deep Research Architecture¶
Autonomous agentic retrieval often balances between two competing operational risks: runaway loops that burn tokens on repetitive queries, and premature cutoffs that leave complex, multi-paper research questions incompletely answered. Zocrates resolves this tension through Human-In-The-Loop (HITL) Deep Research Extensions. When an autonomous search pass exhausts its resource ceiling or reports unaddressed literature gaps, the pipeline deterministically suspends execution and yields an interactive checkpoint card inside the user's chat stream. Researchers can grant up to two deep research extensions—proportionally expanding tool budgets (+5 calls), word ceilings (+3,750 words), and reranking capacity (+5 chunks) while providing optional steering guidance—or synthesize immediately using gathered evidence.
1. Executive Summary & Problem Statement¶
1.1 The Tension in Agentic Literature Retrieval¶
In autonomous agentic retrieval-augmented generation (RAG), orchestrators face a fundamental tension:
- Risk of Runaway Loops & Budget Waste: Unconstrained ReAct search loops can spiral into dozens of redundant queries, burning API tokens, triggering rate limits, and introducing irrelevant context noise into downstream prompts.
- Risk of Premature Cutoff on Complex Literature: Strict hard limits (such as a 10-call cap or 7,500-word ceiling) protect against runaway costs, but terminate retrieval prematurely when researchers ask complex, multi-faceted questions—such as comparative literature reviews across multiple papers, multi-methodology surveys, or granular mathematical proofs.
1.2 Baseline Constraints¶
In Zocrates Dialogue Mode, baseline retrieval operates under three conservative guardrails configured in zocrates_config.json (search_harness):
- Retrieval Tool Budget: 10 calls maximum per pass (
max_retrieval_tools = 10). - Hard Word Ceiling: 7,500 words maximum cumulative retrieved text (
hard_word_ceiling = 7500, ~10,000 tokens). - Cross-Encoder Reranker Cap: Top 15 chunks forwarded to synthesis (
max_rerank_chunks = 15).
1.3 The HITL Philosophy: Deterministic Checkpoints & User Steering¶
Rather than silently truncating context or forcing researchers to manually rephrase and re-submit questions across multiple disjoint turns, Zocrates implements Human-In-The-Loop Deep Research Extensions.
When an initial autonomous search pass identifies remaining knowledge gaps or exhausts its retrieval ceiling, the dynamic workflow suspends execution deterministically, packages its findings, and presents an interactive checkpoint card directly inside the ongoing chat turn:
- "Grant Deep Research Extension" (Primary Action): Proportionally expands all three resource ceilings simultaneously (+5 calls, +3,750 words, +5 rerank chunks) and invokes the search subagent for an additional targeted retrieval pass, optionally steering the agent via natural language guidance.
- "Synthesize Response with Existing Evidence" (Secondary Action): Bypasses further retrieval and proceeds immediately to cross-encoder reranking and academic synthesis using the evidence gathered so far, practicing epistemic honesty regarding unaddressed aspects.
A hard ceiling of 2 extensions per turn (up to 3 total passes) guarantees absolute cost predictability: 20 calls, 15,000 words, and 25 rerank chunks maximum.
Key Implementation Modules¶
| Component | Source Path | Description |
|---|---|---|
| Dynamic Search Pipeline | zocrates/agents/library_search/search_pipeline.py |
Orchestrates the ReAct retrieval loop, search_budget_checkpoint, and ceiling progression |
| Composition Root & Coordinator | zocrates/agents/app.py & coordinator/agent.py |
Top-level application assembly, root coordinator, and multi-agent model coordination |
| Retrieval Toolkit & HITL Tool | zocrates/agents/common/tools.py & toolkit.py |
Implements request_search_extension, tool budget tracking, and ceiling circuit breakers |
| State Constants | zocrates/agents/common/state_keys.py |
Persistent state definitions including HITL_SNAPSHOT and dynamic ceiling keys |
| Streaming Endpoints | zocrates/fast_api_app.py |
Handlers for POST /chat, POST /chat/resume, and POST /sessions/{id}/rewind |
| Request Schemas | zocrates/schemas/chat_request.py |
Pydantic schemas for ResumeChatRequest and session lifecycle operations |
| Session Persistence | zocrates/utils/services.py & session_formatting.py |
DatabaseSessionService SQLite persistence and UI event translation |
| Telemetry & Observability | zocrates/utils/telemetry.py |
OpenTelemetry span enrichment, thought token preservation, and merged tool summarization |
| Frontend Client UI | zotero-plugin/addon/sidebar.js & sidebar.css |
Interactive .hitl-checkpoint-card rendering, bubble continuity, and stream resumption |
2. System Architecture & Dynamic Workflow Lifecycle¶
Zocrates implements HITL using native Google ADK 2.8+ Dynamic Workflows (@node). Dynamic workflows execute Python control flow natively, supporting loop iteration, state persistence, conditional branching, and deterministic suspension via RequestInput.
2.1 Complete End-to-End Sequence Diagram¶
sequenceDiagram
autonumber
actor User as Researcher (Zotero Desktop)
participant UI as Sidebar Client (sidebar.js)
participant API as FastAPI Backend (fast_api_app.py)
participant Runner as ADK Runner (DatabaseSessionService)
participant Coord as Dialogue Coordinator (mode="chat")
participant Pipe as Search Pipeline Node (@node rerun_on_resume=True)
participant Subagent as Search Subagent (mode="single_turn")
participant Checkpoint as Budget Checkpoint Node (@node rerun_on_resume=False)
participant Rerank as Cross-Encoder Reranker (rerank_evidence)
participant Synth as Synthesizer Agent (thinking="high")
User->>UI: Enter prompt (Ctrl+Enter / Cmd+Enter)
UI->>API: POST /chat {prompt, session_id, active_items}
API->>Runner: runner.run_async(session_id, user_message)
Runner->>Coord: coordinator_agent (root agent, mode="chat")
Coord->>Pipe: search_library(request) -> tool_context.run_node(search_library_pipeline_node, override_isolation_scope)
Note over Pipe,Subagent: Pass 1: Autonomous Initial ReAct Retrieval (Budget: 10 calls, 7500w)
Pipe->>Subagent: await ctx.run_node(search_subagent, run_id="search_initial")
loop Autonomous Retrieval
Subagent->>Subagent: search_corpus / get_document_toc / read_document
end
Subagent-->>Pipe: conclude_search(summary, gaps) OR request_search_extension(gaps)
Note over Pipe: Dual Trigger Check: gaps > 0 AND extension_count < 2
Pipe->>Pipe: Persist state[HITL_SNAPSHOT] to SQLite
Pipe->>Checkpoint: await ctx.run_node(search_budget_checkpoint, run_id="checkpoint_0")
Checkpoint-->>Pipe: yield RequestInput(interrupt_id="interrupt_ext_1", payload={...})
Pipe-->>Runner: Raise NodeInterruptedError
Runner-->>API: Stream SSE request_input event
API-->>UI: Deliver SSE event data
UI->>UI: Render .hitl-checkpoint-card inside existing assistant bubble
alt User Grants Deep Research Extension
User->>UI: Click "Grant Deep Research Extension" (+ optional guidance)
UI->>API: POST /chat/resume {session_id, invocation_id, interrupt_id, response: {action: "extend_budget", guidance: "..."}}
API->>Runner: runner._run_node_async(new_message=FunctionResponse("adk_request_input"), node=search_pipeline_node)
Note over Pipe: Workflow Resumes (rerun_on_resume=True)<br/>_ensure_isolation_scope recovers scope<br/>Ceilings expand: 15 calls, 11250w, 20 chunks
Pipe->>Subagent: await ctx.run_node(search_subagent, run_id="search_extension_1", node_input=continuation_prompt)
loop Extension Retrieval
Subagent->>Subagent: Targeted retrieval for remaining gaps
end
Subagent-->>Pipe: conclude_search(updated_summary, updated_gaps)
else User Requests Immediate Synthesis
User->>UI: Click "Synthesize Response with Existing Evidence"
UI->>API: POST /chat/resume {response: {action: "synthesize"}}
API->>Runner: runner._run_node_async(...)
Note over Pipe: Workflow breaks out of extension loop directly
end
Note over Pipe,Synth: Shared Reranking & Terminal Academic Synthesis
Pipe->>Rerank: rerank_evidence(query, evidence, max_chunks, word_ceiling)
Rerank-->>Pipe: Return top ranked evidence chunks
Pipe->>Synth: await ctx.run_node(synthesizer_agent, node_input=grounded_prompt)
Synth-->>Pipe: Return grounded synthesis with citations
Pipe->>Pipe: _enqueue_isolation_scope_closing_event(finish_task="Task completed.")
Pipe->>Pipe: state[HITL_SNAPSHOT] = None (Teardown snapshot & dynamic ceilings)
Pipe-->>Coord: Deliver completed search result
Coord-->>Runner: Stream final answer to user
Runner-->>API: Stream SSE response chunks
API-->>UI: Complete assistant bubble streaming (reuse assistantBubble)
2.2 Dual Trigger Architecture¶
The search pipeline evaluates whether an extension is warranted using two complementary trigger paths:
- Trigger 1: Proactive Agent-Initiated Flag (
request_search_extension): - The search subagent recognizes early that a query spans multiple complex domains that cannot be satisfied within its current budget.
- It invokes the
request_search_extensiontool: -
The tool sets
ctx.state[TEMP_EXTENSION_REQUESTED] = Trueand setsskip_summarization = True. When the subagent's ReAct turn finishes, the parent workflow detects the proactive flag and initiates the checkpoint. -
Trigger 2: Reactive Workflow-Initiated Fallback (Call or Word Exhaustion with Gaps):
- If the subagent exhausts its tool call budget (
current_calls >= current_budget) or cumulative retrieved text reaches 80% of the word ceiling (total_words >= 0.8 * current_word_ceiling), retrieval tools return a soft warning or circuit-breaker block notice. - The subagent calls
conclude_search(summary=..., gaps=[...]). - If the subagent fails to call
conclude_search, the workflow's deterministic_auto_generate_completion_report()safeguard synthesizes a report preserving unaddressed queries. - The workflow checks
len(gaps) > 0. If literature aspects remain unaddressed, the checkpoint triggers reactively.
2.3 Smart Skip Invariance¶
If all literature questions are successfully resolved in the initial pass (len(gaps) == 0) or if HITL is globally disabled in configuration (hitl_enabled = false), the dynamic workflow skips the checkpoint entirely. It bypasses search_budget_checkpoint and proceeds immediately to cross-encoder reranking and synthesis without unnecessary user interruption.
2.4 Checkpoint Execution & Interruption¶
When should_trigger is true, the pipeline executes the checkpoint:
- Serializes current turn state into
ctx.state[HITL_SNAPSHOT]. - Invokes
@node(rerun_on_resume=False) search_budget_checkpointwith: run_id = f"checkpoint_{extension_count}"interrupt_id = f"interrupt_ext_{extension_count + 1}"- The checkpoint node yields ADK's native
RequestInput: - ADK raises
NodeInterruptedError, halting workflow execution. - In
search_pipeline_node, theexcept NodeInterruptedError:block catches the interruption, setsinterrupted = True, and re-raises the exception. - The
finally:block checksif not interrupted:before closing the isolation scope. Becauseinterrupted is True, scope closure is postponed, keeping the session ready for resumption.
3. Proportional Resource Scaling (3-Way Simultaneous Expansion)¶
3.1 Orthogonality of Literature Retrieval Bottlenecks¶
Literature retrieval in academic libraries encounters two distinct types of resource bottlenecks:
- Call-Bound Breadth: Survey queries scanning table-of-contents and frontmatter across 8–10 papers quickly consume 10 tool calls while retrieving relatively few words (~2,500 words).
- Word-Bound Depth: Granular methodological queries reading multi-page sections (
read_document) across 2 papers consume only 3 tool calls but quickly exceed 7,000 words.
Scaling only one dimension fails whenever a query is bound by the other. Furthermore, increasing retrieval capacity without expanding downstream reranking causes newly retrieved evidence to be discarded before reaching the synthesizer.
3.2 The Multi-Extension Progression Matrix¶
To resolve this, Zocrates scales all three retrieval dimensions simultaneously upon each granted extension:
| Progression Stage | Retrieval Tool Calls | Hard Word Ceiling | Max Rerank Chunks | Operational Behavior |
|---|---|---|---|---|
| Pass 1 (Baseline) | 10 calls | 7,500 words (~10k tok) | 15 chunks | Baseline autonomous ReAct retrieval |
| Extension 1 (Grant #1) | 15 calls (+5) | 11,250 words (+3,750w) | 20 chunks (+5) | First deep research extension pass |
| Extension 2 (Grant #2) | 20 calls (+5) | 15,000 words (+3,750w) | 25 chunks (+5) | Second deep research extension pass |
| Terminal Cap | — | Trimmed to ≤ 15,000w | Top 25 chunks | Hard Stop: Loop terminates to synthesis |
3.3 Dynamic State Ceilings¶
Resource limits are maintained dynamically in session state:
TEMP_RETRIEVAL_TOOL_BUDGET(temp:retrieval_tool_budget)TEMP_WORD_CEILING(temp:word_ceiling)TEMP_MAX_RERANK_CHUNKS(temp:max_rerank_chunks)TEMP_EXTENSION_COUNT(temp:extension_count)
Upon granting an extension, search_pipeline_node computes new ceilings:
new_budget = current_budget + budget_increment # +5
new_word_ceiling = current_word_ceiling + word_increment # +3750
new_max_chunks = current_max_chunks + chunk_increment # +5
new_extension_count = extension_count + 1
4. State Persistence & Rehydration Across Restarts (hitl_snapshot)¶
4.1 The ADK temp: State Stripping Mechanism¶
In Google ADK, BaseSessionService strips all state keys prefixed with temp: before persisting session updates to SQLite (sessions.db):
# ADK internal session persistence filter:
persisted_state = {k: v for k, v in state.items() if not k.startswith("temp:")}
temp:retrieved_evidence, temp:search_report, temp:retrieval_tool_count) is purged from SQLite.
4.2 Persistent Checkpoint Snapshot Schema¶
To make HITL checkpoints fully durable across process restarts, search_pipeline_node serializes full turn state to the non-prefixed, persistent state key ctx.state[HITL_SNAPSHOT] immediately prior to yielding RequestInput:
state[HITL_SNAPSHOT] = {
"evidence": evidence,
"search_report": search_report,
"extension_count": extension_count,
"tool_count": current_calls,
"extension_requested": extension_requested,
"isolation_scope": active_isolation_scope,
"completed_extensions": list(completed_extensions),
"ceilings": {
"budget": current_budget,
"word_ceiling": current_word_ceiling,
"max_chunks": current_max_chunks,
},
"seen_chunk_ids": list(state.get(TEMP_SEEN_CHUNK_IDS, [])),
"seen_assets": list(state.get(TEMP_SEEN_ASSETS, [])),
}
4.3 Avoiding ADK State._delta Shadowing via Full-Dictionary Reassignment¶
ADK's State class tracks mutations by recording assignments to State._delta. In-place mutations to nested dictionaries (e.g. state[HITL_SNAPSHOT]["ceilings"]["budget"] = 15) do not trigger State.__setitem__ and fail to persist to SQLite. Furthermore, if a dynamic workflow assigns to keys managed by child tools, it can shadow tool-level mutations.
Zocrates strictly enforces full-dictionary reassignments:
Every update produces a brand-new top-level dictionary assignment, guaranteeing detection byState._delta and atomic persistence to sessions.db.
4.4 In-Memory Fallback Rehydration on Re-entry¶
When a session resumes from disk where temp: keys were stripped:
evidence = state.get(TEMP_RETRIEVED_EVIDENCE) or (snapshot.get("evidence") if snapshot else None) or []
search_report = state.get(TEMP_SEARCH_REPORT) or (snapshot.get("search_report") if snapshot else None)
snapshot in-memory without prematurely writing back to temp:retrieved_evidence. This leaves child tool contexts clean to append subsequent retrieved chunks.
4.5 Turn-End Teardown & Cross-Turn Cleanup¶
Once final synthesis concludes, search_pipeline_node purges the snapshot and resets all dynamic ceilings:
state[HITL_SNAPSHOT] = None
state[TEMP_EXTENSION_REQUESTED] = False
state[TEMP_EXTENSION_REQUEST_PASS] = None
state[TEMP_EXTENSION_COUNT] = None
state[TEMP_RETRIEVAL_TOOL_BUDGET] = None
state[TEMP_WORD_CEILING] = None
state[TEMP_MAX_RERANK_CHUNKS] = None
5. Scope Recovery & Context Boundary Enforcement¶
5.1 Root Runner Context Reset on Resumption¶
When an interrupted session is resumed via runner._run_node_async(), ADK constructs a brand-new root context for the resumption runner where ctx.isolation_scope = None. ADK's _run_node_async does not accept an override_isolation_scope argument.
If the search pipeline were to execute with None, two critical failures would occur:
- Event Scope Mismatch: Pipeline child events would be emitted under
isolation_scope = None, polluting the outer coordinator conversation history. - Loss of Prior Subagent History: Subagents configured with
include_contents="default"would no longer match thesearch_pipeline:fc_idscope of prior turns, losing their memory of already retrieved chunks and queries.
5.2 Self-Sufficient Scope Recovery (_ensure_isolation_scope)¶
To guarantee scope continuity across resumptions, search_pipeline_node executes _ensure_isolation_scope(ctx) as its very first operation:
def _recover_isolation_scope(ctx: Context) -> str | None:
"""Recover the search pipeline's isolation scope from snapshot or session events if unset."""
if getattr(ctx, "isolation_scope", None):
return ctx.isolation_scope
state = getattr(ctx, "state", None)
if state:
snapshot = state.get(HITL_SNAPSHOT)
if isinstance(snapshot, dict) and snapshot.get("isolation_scope"):
return snapshot["isolation_scope"]
# Fall back to scanning session events for active invocation
curr_inv_id = getattr(ctx, "invocation_id", None) or ...
session = ...
if session and getattr(session, "events", None):
for event in reversed(session.events):
if getattr(event, "invocation_id", None) == curr_inv_id:
scope = getattr(event, "isolation_scope", None)
if scope and str(scope).startswith("search_pipeline:"):
return str(scope)
return None
_ensure_isolation_scope(ctx) assigns the recovered scope directly onto ctx.isolation_scope and ctx._invocation_context.isolation_scope, ensuring all child nodes inherit the original isolation scope.
5.3 Invocation-Scoped Event Scans (curr_inv_id)¶
In multi-turn chat sessions where multiple research queries occur across turns, earlier turns also contain search_initial runs and checkpoint events.
To prevent historical turns from corrupting the active turn, all session event scans in search_pipeline_node:
has_initial_rundetection_recover_isolation_scope_get_pipeline_interrupt_ids
filter strictly by matching event.invocation_id == curr_inv_id. This guarantees that subsequent research questions in Turn 2 or Turn 3 never skip their own initial retrieval passes.
5.4 Root Conversation Context Isolation & _SINGLE_TURN_NUDGE Suppression¶
In ADK 2.8, agents operating in mode="single_turn" inspect ctx._invocation_context.user_content. In a resumed session, user_content holds the user's root conversational message (e.g. "Can you survey the CamKII literature?"). ADK automatically prepends this root prompt as contents[0] and appends _SINGLE_TURN_NUDGE ("Important: You will not receive any user replies or clarifications...").
This causes severe cognitive dissonance: subagents spend reasoning tokens trying to converse with the user rather than executing retrieval tools.
To eliminate this:
if hasattr(ctx, "_invocation_context") and ctx._invocation_context is not None:
ctx._invocation_context.user_content = None
node_input without conversational banter or _SINGLE_TURN_NUDGE prompts.
Furthermore, when an extension checkpoint pauses and resumes, ADK records adk_request_input FunctionCall and FunctionResponse events in the shared pipeline scope. While ADK natively filters internal framework events from LLM context, it omits adk_request_input. Without filtering, ADK's _fencing module wraps the checkpoint events in cross-agent fencing (<<<BEGIN_QUOTED_AGENT_CONTENT>>>) and injects them into search_subagent's prompt.
Zocrates resolves this via patch_adk_request_input_context_filter() in zocrates/agents/common/adk_compat.py:
- Idempotently wraps
google.adk.flows.llm_flows.contents._should_include_event_in_context. - Filters out
adk_request_inputfunction calls and responses from LLM prompt construction while preserving them in SQLite session history and runner interrupt resolution. - Subagents receive exclusively their prior tool calls and their clean, structured continuation prompt.
5.5 Terminal Isolation Scope Closure (finish_task)¶
ADK's Runner._find_active_task_scope scans session.events backwards to determine whether any background task agent is awaiting input. Any non-empty isolation_scope is treated as active unless a finish_task FunctionResponse ("Task completed." or "Task failed.") is recorded.
Without terminal closure, ADK stamps Turn 2+ user messages with the pipeline's old isolation scope, causing the coordinator (isolation_scope = None) to filter them out and re-execute Turn 1.
search_pipeline_node executes terminal scope closure in a finally block:
finally:
if not interrupted:
await _enqueue_isolation_scope_closing_event(
ctx,
result="Task completed." if completed else "Task failed.",
)
interrupted is True, leaving the scope open. When final synthesis completes, _enqueue_isolation_scope_closing_event cleanly finishes the task.
6. Replay Sequence Barrier & Guarded Chronological Execution¶
6.1 ADK 2.8 Dynamic Replay Sequence Barrier (ReplaySequenceBarrier)¶
In ADK 2.8, dynamic workflows decorated with @node(rerun_on_resume=True) reconstruct an execution sequence barrier of all prior child node executions for that invocation. On resumption, the workflow function re-executes from line 1 in order to rebuild in-memory variables and state.
ADK verifies that child node executions occur in the exact same chronological sequence:
Replay Sequence = [checkpoint_0, search_extension_1, checkpoint_1, ...]
If the workflow attempts to skip loop iterations based on persisted state counters (e.g. starting for extension_count in range(persisted_count, max_extensions)), the scheduler encounters a Replay Divergence Timeout waiting for earlier sequence barrier events that were never scheduled.
6.2 Deterministic Replay Alignment & Execution Guard¶
To satisfy the replay sequence barrier while preventing duplicate execution of already-finished agent passes, Zocrates uses guarded chronological replay:
- The extension loop always iterates chronologically starting from
extension_count = 0: - The workflow tracks completed extension passes in
completed_extensions: list[int]insidehitl_snapshot. - Agent invocation is guarded:
On Extension 2 resumption, Pass 1 checkpoint resolves immediately from the event log,
if new_extension_count not in completed_extensions: await ctx.run_node( search_agent, node_input=continuation_prompt, run_id=f"search_extension_{new_extension_count}", override_isolation_scope=active_isolation_scope, ) completed_extensions.append(new_extension_count)search_extension_1is safely skipped via the guard, Pass 2 checkpoint resolves from the new resume event, andsearch_extension_2executes cleanly.
7. Frontend Architecture, Wire Protocol & UI Components¶
7.1 Wire Contract: POST /chat/resume¶
When the user interacts with the checkpoint card in Zotero, sidebar.js sends an HTTP POST request to /chat/resume:
POST /chat/resume HTTP/1.1
Content-Type: application/json
{
"session_id": "session-1726000000",
"invocation_id": "inv-987654",
"interrupt_id": "interrupt_ext_1",
"response": {
"action": "extend_budget",
"guidance": "Focus specifically on the ablation benchmarks in Table 3."
}
}
Request Schema (ResumeChatRequest)¶
session_id(str): Target chat session ID.invocation_id(str): Active invocation ID holding the pending interrupt.interrupt_id(str): Unique interrupt identifier (e.g."interrupt_ext_1").response(dict[str, Any]):action("extend_budget" | "synthesize"): Selected user action.guidance(str | None): Optional steering guidance.
7.2 Idempotency & 409 Conflict Protection¶
To prevent race conditions, duplicate clicks, or session corruption:
fast_api_app.pyinspects_get_unresolved_session_interrupts(session).- If
request.interrupt_id not in pending_interrupts, it rejects the request immediately with409 Conflict("Invalid or stale interrupt ID. The session is not waiting for this interrupt."). - If a user attempts to send a new prompt via
POST /chatwhile an unresolved checkpoint is pending,POST /chatreturns409 Conflict, instructing the user to resolve or rewind the pending checkpoint.
7.3 DOM Construction of .hitl-checkpoint-card¶
Inside Firefox's Gecko engine in Zotero, unescaped HTML entities or unstructured HTML strings passed to innerHTML fail or trigger XML parser errors. sidebar.js constructs the interactive checkpoint card structure using strict XML DOM methods (document.createElementNS("http://www.w3.org/1999/xhtml", ...)), restricting innerHTML usage strictly to localized button labels and icon spans:
function renderHitlCheckpointCard(container, data) {
const card = document.createElementNS(XHTML_NS, "div");
card.className = "hitl-checkpoint-card";
// Extension Badge (e.g. "Deep Research: Extension 1 of 2")
// Covered Findings Summary section
// Unaddressed Literature Gaps list (<ul> / <li>)
// Optional Guidance Input (<input type="text" placeholder="Steer search...">)
// Primary Button: "Grant Deep Research Extension (+5 Calls, +50% Words)"
// Secondary Button: "Synthesize Response with Existing Evidence"
}
7.4 Bubble Continuity & Multi-Pass Stream Resumption¶
When /chat/resume streams SSE events, sidebar.js maintains complete visual continuity:
- It does not create a new chat bubble.
- It reconnects the SSE reader directly into the active
assistantBubbleand appends to the existingliveStepsarray. - Intermediate tool calls from Extension 1 and Extension 2 append cleanly into the collapsed
.intermediate-steps-bubble. - When synthesis arrives, it streams directly into the same assistant message body.
7.5 Cross-Session Restart Rehydration (pending_hitl)¶
If the user closes Zotero while an HITL checkpoint is paused and re-opens it later:
sidebar.jscallsGET /sessions/{id}.format_session_for_ui(session)inspects session events. If anadk_request_inputfunction call is present without a correspondingFunctionResponse, it populates:loadActiveSessionMessages()detectspending_hitlon the last message and automatically re-renders the interactive.hitl-checkpoint-card, allowing the user to resume seamlessly.
7.6 Session Rewind Support (POST /sessions/{id}/rewind)¶
If a researcher wishes to cancel a pending checkpoint and discard the incomplete turn, they can trigger session rewind:
- Endpoint:
POST /sessions/{session_id}/rewindwith{"invocation_id": "..."}. - Invokes ADK's
runner.rewind_async(), removing all events associated with that invocation from SQLite and returning the session to its clean prior state.
8. Observability, Telemetry & Arize Phoenix Integration¶
8.1 Phoenix Span Hierarchy¶
Zocrates traces dynamic workflows using OpenTelemetry exported to a local Arize Phoenix collector (http://localhost:6006). An HITL research turn produces a structured span tree:
POST /chat
└── coordinator_agent [AGENT]
└── search_library [TOOL]
└── search_library_pipeline_node [WORKFLOW]
├── search_initial [AGENT]
│ ├── generate_content [LLM]
│ └── execute_tool: search_corpus [TOOL]
├── checkpoint_0 [WORKFLOW]
│ └── adk_request_input [TOOL]
└── (Pause / Resume)
├── search_extension_1 [AGENT]
│ ├── generate_content [LLM]
│ └── execute_tool: read_document [TOOL]
├── rerank_evidence [TASK]
└── synthesizer_agent [AGENT]
└── generate_content [LLM]
8.2 Preserving thought: True across ADK Semconv¶
During streaming LLM calls, reasoning models emit token chunks with thought=True. By default, Google ADK's _experimental_semconv._to_part() strips the thought attribute when serializing parts into gen_ai.output.messages.
Zocrates applies an idempotent patch in zocrates/utils/telemetry.py (_patch_adk_semconv_to_part):
def patched_to_part(part: Any, idx: int, *args: Any, **kwargs: Any) -> Any:
res = orig_to_part(part, idx, *args, **kwargs)
if res is not None and getattr(part, "thought", False):
res["thought"] = True
return res
gen_ai.output.messages and gen_ai.input.messages, enabling Phoenix to render distinct thought process and response sections.
8.3 Multi-Turn gen_ai.input.messages & input.value Consolidation¶
When a dynamic workflow replays prior turns, hundreds of streamed token chunks are passed back into gen_ai.input.messages. Without consolidation, every streaming token is formatted as a separate [assistant] ... line, resulting in thousands of lines of clutter in Phoenix.
PhoenixEnrichmentSpanProcessor.on_end consolidates input messages:
- Assistant turns: Parsed through
_format_parts_with_labeled_thoughts, consolidating streaming tokens into at most two clean parts (one Thought section and one Response section). - User turns: Text chunks joined cleanly with
"".join(...). - The span attributes
input.valueandgen_ai.input.messagesare updated in place, rendering single, unified speech bubbles in Phoenix's Chat Viewer.
8.4 Suppressing Merged Tool Event Bloat¶
When parallel or merged tools run, ADK attaches the entire session Event JSON (including the full temp:retrieved_evidence array) to gcp.vertex.agent.tool_response. For multi-paper queries, this produces multi-megabyte JSON payloads in Phoenix.
_summarize_merged_tool_response() in zocrates/utils/telemetry.py intercepts execute_tool (merged) spans, extracting tool names, call IDs, and execution statuses into a compact summary ({"merged_tools": [{"tool": "search_corpus", "id": "...", "status": "success"}]}) while stripping state_delta bloat.
9. Verification & Testing Matrix¶
The HITL architecture is covered by comprehensive automated tests across unit, integration, and telemetry layers:
9.1 Unit Test Coverage (tests/unit/test_hitl.py)¶
test_hitl_snapshot_persistence_and_rehydration: Simulates SQLite reload wheretemp:keys are stripped, confirming seamless rehydration fromhitl_snapshot.test_hitl_snapshot_cleared_on_completion_prevents_turn2_contamination: Confirms turn-end teardown clears snapshot and prevents Turn 2 contamination.test_proactive_request_search_extension_tool: Verifiesrequest_search_extensiontool setsTEMP_EXTENSION_REQUESTED,skip_summarization=True, and populates gap metadata.test_check_and_increment_tool_budget_notices_and_ceilings: Tests tool budget tracking, soft warnings at Call 9, and dynamic ceiling enforcement.test_unresolved_session_interrupts_scanner: Verifies detection and scanning of pendingRequestInputevents in session history.test_resume_chat_endpoint_validates_interrupt: Asserts validation of activeinterrupt_idand 409 Conflict rejection for invalid or mismatched interrupt IDs.test_format_session_skips_fr_only_user_events: Confirms resumeFunctionResponseuser turns are filtered out of UI chat history to prevent blank bubbles.test_format_session_rewind_prunes_pending_hitl: Verifies session rewind prunes unresolvedRequestInputevents and resets pending status.
9.2 Integration Test Coverage (tests/integration/test_hitl_integration.py)¶
test_hitl_call_budget_exhaustion_triggers_request_input_sse: End-to-endPOST /chattriggering call exhaustion and verifying the SSErequest_inputpayload.test_hitl_proactive_request_search_extension_triggers_request_input_sse: End-to-end verification ofrequest_search_extensiontool invocation.test_hitl_resume_with_extend_budget_and_synthesis: Complete multi-pass execution testingPOST /chat->POST /chat/resumewithextend_budget-> terminal synthesis.test_hitl_resume_with_synthesize_immediately: Tests immediate synthesis flow viaPOST /chat/resume.test_hitl_pending_interrupt_returns_409_on_new_chat: Confirms 409 Conflict guard against starting new chats during pending interrupts.test_hitl_sqlite_persistence_and_resume_roundtrip: Verifies real SQLite disk persistence and resume roundtrip across session reloads.test_hitl_second_extension_and_hard_stop_on_third: Tests Extension 1 -> Extension 2 -> hard cutoff on Extension 3.test_hitl_second_research_turn_in_same_session_does_not_skip_search_initial: Confirms invocation scoping prevents Turn 1 snapshots from bypassing Turn 2 initial retrieval.
9.3 Observability & Telemetry Test Coverage (tests/unit/test_telemetry.py)¶
test_patch_adk_semconv_to_part_preserves_thoughts: Verifiesthought: Trueis preserved in ADK semconv.test_phoenix_enrichment_span_processor_consolidates_input_messages: Confirms streaming token consolidation ingen_ai.input.messagesandinput.value.test_phoenix_enrichment_span_processor_merged_tool_span: Verifies suppression of multi-MBstate_deltain merged tool spans.
10. Related Architecture Documentation¶
- Dialogue Agent & Dynamic Search Pipeline: Specifications for the outer conversational coordinator, ReAct search subagent, and cross-encoder reranker.
- In-Text Citations, Verification & Two-Tier Sources Architecture: Specifications for the asynchronous AI decision-model verification pre-pass, flat single-ID citations, and interactive evidence popovers.
- System Overview: High-level architectural map of the frontend plugin, backend multi-agent pipeline, and deployment configurations.