loop = "investigative" in an agent definition, simple stays the default). This page is the specification the implementation follows. It defines the concepts, the data structures, the DB schema and the behaviour of the loop; it does not prescribe code layout beyond the constraints in Constraints. The agent as it works today is described in deep-research-agent.md; sub-agents in subagents.md; the job around it in research.md.
Why
Today the research agent is a plain tool-calling loop whose conversation only grows, and whose stop condition is “the model wrote text instead of calling a tool”. That gives four failure modes:
The agent becomes an investigator: it keeps explicit state, decides what to find out next, acts, observes, and updates the state, testing hypotheses, triangulating sources and correcting itself.
Concepts
- Run: one research (an existing
gb_agent_research_runsrow). Everything below is scoped to a run. - Objective: the topic of the run; the root question of the frontier.
- Question: an unresolved query, hypothesis or knowledge gap; a node of the frontier graph.
- Frontier: the set of questions of a run with their edges (parent, dependencies). It is the active boundary between what is known and what must be found next.
- Claim: an atomic factual assertion with its sources and a verification status.
- Datapoint: a claim that is a number with a unit and context.
- Notes (
notes.md): the text scratchpad of the run, the agent’s working state. - Turn: one model call plus the tools it asked for.
- Iteration: one pass of the cycle on one question; usually several turns.
- Circuit-breaker: a deterministic limit that ends the loop regardless of what the model wants.
Constraints
- Functional style (mandatory, see
AGENTS.md): the frontier, the notes and the loop state are immutable values; every transition is a pure function(state, event) -> state; reads and writes to the DB, the notes file and the model are at the edge (an effect layer that executes what the pure core returned). Selection, pruning, gating and breaker checks are pure functions of the state and are unit-testable without a model or a DB. - Minimal: no framework, no new service, no concept that the sections below do not need.
- Deterministic where possible: everything that decides whether the loop continues (selection order, dependency checks, depth cap, attempt cap, breakers, finalization gate) is code, not model judgement. The model decides content: what to ask, how to search, what an answer says, whether it meets the criteria.
- The frontier lives outside the prompt and outside
notes.md, in its own tables. The model touches it only through tools.
The cycle
Each iteration is state -> decide -> act -> observe -> update:- State. The loop builds the prompt from: the task (objective), the tool definitions, the current
notes.md, the frontier summary, and the last 1 to 2 turns of raw tool input/output (see context compaction). Nothing else. - Decide. Code selects the question to work on (see selection). The model is told which question it is, with its rationale, validation criteria and already-attempted queries, and formulates the next action to satisfy the validation criteria, not generic browsing.
- Act. The model calls tools:
web_search,fetch_page,query, and the frontier and notes tools. - Observe. Results come back. A large
fetch_pageresult triggers the forced extraction before anything else. The model evaluates what it has against the validation criteria of the question. - Update. The model records the outcome through tools (resolve, reformulate, spawn children, record claims); the code applies the deterministic parts (attempt counting, blocking, pruning, re-ranking), runs the breaker check and starts the next iteration.
finalize_report or a circuit-breaker (below); a turn where the model answers with plain text and no tool is not an end: the loop reminds it of the open frontier and continues.
Hypothesis testing, triangulation, self-correction
These are not extra machinery; they are uses of the frontier and the claim ledger:- Hypothesis testing. A question may be a hypothesis (
kind = 'hypothesis': “X is true because Y”). Its validation criteria state what would confirm it and what would refute it. The agent searches for both: one query for confirming evidence and an adversarial one for refuting evidence (see adversarial queries). Result:resolved(confirmed or refuted: the claim ledger records which), orunresolvable. - Source triangulation. A claim becomes
VERIFIEDonly with support from at leastmin_independent_sources(default 2) independent sources (different registrable domain and not one quoting the other; the agent states the independence in the claim record). One source alone leaves itUNVERIFIED. - Self-correction. Anything recorded can be revised: a claim can be
REFUTEDorRETRACTED, a resolved question can bereopenedwhen a later finding contradicts its answer (thereopen_questiontool; it re-enters the queue with raised priority and a note why), and a refuted claim spawns a high-priority question to resolve the contradiction. Revisions are appended, not overwritten, so the history stays auditable.
The question frontier
An explicit, prioritized queue of unresolved questions, treated as a best-first search over a knowledge space: every node is a question, every edge a retrieval step, the frontier stores the candidate nodes waiting to be evaluated. It is the termination criterion too: the exploration phase is finished when no actionable question remains (or a breaker fires).Question record
Dependencies are edges (
question_id depends on depends_on_id), kept in their own table so they can be validated: a question is actionable only when every dependency is resolved (a dependency that is pruned or unresolvable makes the dependant blocked; the agent must then rewrite or drop it). Dependencies must form a DAG; adding an edge that would create a cycle is rejected.
Each query tried on a question is a row of the attempt log (question_id, the query text, tool, outcome summary, time). It is what the agent sees as “already attempted” and what drives mutation and the dead-end rule.
Example (as the tools return it):
retrieval_strategy is a view: target_sources is a column, attempted_queries comes from the attempt log.)
Lifecycle
pending: waiting in the queue.in_progress: selected; at mostmax_parallelat a time (1 for a single worker).resolved: validation criteria met; the answer and its claim ids are stored.blocked: a dependency can never be satisfied; needs a rewrite by the agent, or it is pruned.unresolvable: failed aftermax_attemptsdistinct reformulations. It is not retried; the gap and the assumption made go into the report.pruned: dropped from the search (with a reason). Terminal, but kept in the table for the audit trail.
resolved (can be reopened), unresolvable, pruned.
Every status change is recorded as a new row in gb_agent_frontier_transitions (append-only, one row per change, never an update of an old row), referencing the question by (run_id, question_id). The row holds the old and new status, what triggered it, whether the agent or a deterministic rule did it, the reason, the priority and attempt count at that moment, and what caused it (another question or a challenge). The same transaction updates the current status of the question, so a question is a row (current value) plus its transitions (history). Creation is the first transition (created, no old status); a selection, a retry that returns the question to pending, a reopen, an automatic block or unblock, and every prune (including each descendant pruned by prune_branch, one row each) are all transitions. A change of priority alone is not a status change: it is a transition with from_status = to_status and trigger reprioritized, so priority history is on the same timeline.
Selection
Pure function over the frontier: amongpending questions whose dependencies are all resolved, pick the highest priority; ties go to the lower depth (broader first), then to the older one. If the highest-priority pending question is not actionable because of an unresolved dependency, the dependency is considered first (its own priority is raised to at least that of its dependant, so unblocking work is never starved). Selecting sets in_progress.
Evaluation and mutation
After acting, the model judges the observations against the question’svalidation_criteria and calls one of:
resolve_question(id, answer, claim_ids): the code checks that claim ids exist, that ahypothesiscarries both a confirming and a refuting search in its attempt log (or an explicit reason), and that each cited claim is at leastUNVERIFIED; it then setsresolved.retry_question(id, new_query, why): inconclusive; logs the attempt (the code rejects a query that is not different from earlier ones) and returns the question topending. Whenattemptsreachesmax_attempts(default 4) the code setsunresolvableinstead and requires anassumptiontext.
Branching (node expansion)
add_question(...) creates a child of the current question when a document brings:
- a contradiction with something recorded,
- an unexpected dependency (a prerequisite fact the agent did not know it needed),
- a knowledge gap that blocks the validation criteria.
id and depth, rejects the node when depth > max_depth (default 3; returned to the agent as “pruned by depth cap; record it as an open gap in notes instead”), rejects a near-duplicate of an existing question, and enforces max_questions per run. A child that unblocks the current question is added as a dependency of it and gets priority at least as high as the current question: an anomaly is resolved before the agent moves forward.
Pruning and re-prioritization
After every update the loop runs pure cleanups, and the agent can request more:- Scope pruning.
prune_branch(id, reason): drops the question and allpending/blockeddescendants (recursively). Used when an answer makes a branch irrelevant (a library found deprecated removes all questions about configuring it). The prompt tells the agent to prune any question whose answer would not alter the top-level conclusion; the reason is stored. - Depth cap. Nodes deeper than
max_depthare never created. - Dead ends.
unresolvableaftermax_attempts, never spun on. - Re-prioritization.
set_priority(id, priority, why). The code also re-ranks mechanically: raising a question raises the floor of its dependencies; resolving a question lowers nothing but unblocks dependants; a pruned branch removes its weight from ancestors.
Frontier tools
All mutations go through tools; each returns the updated compact summary of the affected questions. The full frontier is never dumped into the prompt; only the summary.
At the start of a run the agent performs broad query expansion: from the objective it creates the first level of sub-questions (typically 3 to 8, each with criteria and priority) before the first search. The first
frontier_next is rejected until the root has at least one child (or the agent states that the objective is atomic).
Working state: notes.md
The agent’s externalized working memory for the run: a text file the agent reads (it is always in the prompt) and writes through tools. It holds knowledge and reasoning, while the frontier (control flow) is in the DB.
Storage has two parts, both per run:
- Working copy: a file. Its name is
notes_<user>_<run>_<notes_id>.md, in the notes directory (server configresearch_notes_dir, default in the research data directory next to the other research files).<user>is the run’suser_idwith every character outsideA-Z a-z 0-9 . _ -replaced by_, cut to 64 characters. Auser_idis any string (see identity.md), so it must be sanitized. It is in the name for readability only: uniqueness comes from<run>and<notes_id>.<run>isgb_agent_research_runs.id.<notes_id>is a random 128-bit id (32 hex characters) made when the run is created and kept in the run’snotes_idcolumn. Two servers sharing a directory, or a database whose ids were reused, cannot overwrite each other’s files.- The file is written to a temp file in the same directory and renamed over the old one.
- History: the database. Every write also adds a full copy of the file to
gb_agent_notes_versions(see schema). The file is the copy the loop reads and edits; the versions are the record for debugging and export. If the file is missing when a turn starts, the loop restores it from the latest version.
- Ids (
c_,d_,s_) are assigned by code when the agent appends; the agent refers to them in the frontier tools and in the report. - One record per line, in a fixed grammar, so that the gate and the compaction can read them with a pure parser. Anything in
Scratchis free text and not parsed. - Edits go through tools:
notes_append(section, entries),notes_update(id, change)(change status, add a source, retract),notes_scratch(text). The agent does not rewrite the file wholesale, so a model slip cannot erase verified work. - Size bound: the file has a budget in tokens (
notes_max_tokens, default 6000). When it is over, the loop forces a consolidation turn (merge duplicates, shortenScratch, move the settled toVerified) before continuing. This keeps the prompt bounded. - Every write adds a version row to
gb_agent_notes_versions, in the same transaction as the claim/source/transition rows it changes, so the versions and the structured history always agree.
Extraction
Forced extraction
fetch_page (the renamed, richer successor of fetch_url) returns the page text. When it returns more than extraction_threshold characters (default 4,000), the loop does not let the agent continue with the raw text sitting in the conversation:
- The next turn is restricted: only the notes tools are available, and the instruction is “extract what matters for question
q_xxxfrom this page”. - The agent records atomic claims (one assertion each, self-contained), numeric datapoints (value, unit, context) and the source link (with title) in
notes.md(claims startUNVERIFIED, linked to the source id). - When the turn ends, the raw page text becomes eligible for eviction (see below). A page for which nothing was extracted is recorded as a source with a line “read, nothing relevant” so it is not fetched again.
query answer, a long search briefing).
Claims and cross-checking
A claim has:id, text, status, source ids, supports / contradicts links to other claims, and the question that produced it.
Procedure:
- On finding a primary claim, the agent records it
UNVERIFIED. - For each
UNVERIFIEDclaim that matters to a question, the loop makes the agent challenge it with an adversarial query (see Adversarial queries). Every challenge is recorded in its own table. - Supporting evidence from a source that is independent of those already listed moves the claim to
VERIFIED(notes_update); the code checks the source count and independence declaration, the model cannot markVERIFIEDwith fewer. - Contradicting evidence sets a
contradictslink, moves the claim toREFUTEDor leaves both claims inContradictions, and spawns a high-priority question (branching above).
VERIFIED claims (and explicitly flagged UNVERIFIED ones, marked as such) can appear in the report.
Adversarial queries
An adversarial query is a search whose purpose is to find evidence against something the agent currently believes. The point is to test, not to look for more of the same: a query that would return the same pages as the one that produced the claim is not a challenge. Every challenge is a row ingb_agent_challenges, so that how the model attacks its own facts and conclusions can be inspected afterwards (what it targeted, how it phrased the attack, what came back, and whether the belief survived).
What can be challenged (target_type):
- a
claim(an atomic fact or datapoint): challenged when it isUNVERIFIEDand matters to a question’s criteria, and again before it is allowed to count asVERIFIEDif it was verified only by sources with a common origin; - a
question_answer: the answer of a question beingresolved, challenged beforeresolve_questionis accepted for a question of depth 0 or 1 (the ones that carry the conclusion); - a
conclusion: the overall conclusion of the run, challenged once at the end of exploration (the conclusion is stated in a few sentences innotes.md, sectionScratch, and referenced by the challenge). This is what keeps the agent from stopping on the first plausible story.
Strategies (
strategy), the vocabulary the model chooses from, so challenges can be counted and compared:
Rules the code enforces (pure checks on the challenge value):
- A challenge names its target (
claim_id,question_idor the conclusion) and astrategy; it carries at least oneexpected_if_false. - The
querydiffers from every earlier query on the question (and from the queries of earlier challenges of the same target), normalized; a rephrasing is rejected. - The same
strategyis not used twice on the same target; at mostmax_challenges_per_target(default 3) on one target. - The query must not be only the target’s own text: it must contain terms of the
negation/strategy that the original claim text does not (a cheap string test, so “claim + the word ‘not’” is allowed and “claim again” is not). - It is executed as a normal
web_search/fetch_page, and is also written to the attempt log of the question (the challenge row points to that attempt).
record_challenge_result with a verdict:
The verdict comes with the evidence:
evidence (a short text, what was found), and the source ids. The code requires refuted and weakened to cite at least one source, so the model cannot declare a refutation from nothing; and it limits survived to a challenge that was executed (has an attempt). result_claim_id links the new claim that the counter-evidence produced, if any.
When the loop requires challenges.
- A claim cannot become
VERIFIEDwithout at leastmin_challenges(default 1) challenges with outcomesurvivedon it, on top of the source count. resolve_questionfor depth 0 and 1 questions requires a challenge of the answer (question_answer), or a stored reason.finish_explorationrequires a challenge of theconclusionwith a recorded outcome (see the gate); if it isweakenedorrefutedthe exploration continues on the questions this opened.- After a
refutedoutcome on a claim, every question that cited that claim in itsansweris reopened by code.
A challenge is
open between challenge and record_challenge_result. Open challenges appear in the frontier summary of the prompt, so the agent cannot forget to close one; the loop will not start finish_exploration with one open.
What it enables for analysis. Because the strategy, the negation, the query, the outcome and the evidence are structured, one can answer: how often does the agent challenge at all; which strategies it favours and which find the refutations; how many claims survive; whether the challenges are real (are queries distinct, do they use new terms) or token (all survived, all with the same strategy); which sources refuted what; and how the model’s conclusion changed. See the observability section.
Context compaction
The active message history is rebuilt each turn, not accumulated. The prompt of a turn contains only:- the system prompt and the tool definitions,
- the task (the objective, as the first user message),
- the scratchpad: the current
notes.mdand the frontier summary (see below), - the last 1 to 2 turns of raw tool input/output (
keep_turns, default 2).
notes.md, or the loop has established that nothing in it is useful (recorded in the attempt log with an outcome summary). The attempt log keeps the queries and a one-line outcome, so the agent still knows what it tried without the snippets. An evicted result is replaced by nothing in the history (the scratchpad is the memory), not by a stub, apart from the attempt-log line.
Consequence: the cost of a turn is bounded by task + tools + notes (<= notes_max_tokens) + frontier summary + 2 turns, not by the number of turns so far, so the run can have many more iterations than today’s 20 steps.
Uncommitted results (the agent has not extracted yet) are never evicted: the forced-extraction step guarantees a commit point before the window moves on.
Prompt contents
Frontier summary rendered into the prompt (compact, bounded):Two loops
To prevent premature termination, a research is two loops run one after the other, with different tools, prompts and goals.Exploration loop
Goal: gather and validate evidence until the frontier is exhausted. Tools: web/knowledge tools, frontier tools, notes tools,finish_exploration. The model cannot write the report here. It ends when:
finish_explorationis called and the exploration gate passes, or- the frontier has no actionable question left (the loop then does a final check: any
UNVERIFIEDclaim relevant to a resolved question is queued for verification; if there is none, exploration ends), or - a circuit-breaker fires.
Synthesis loop
Goal: write the report from the notes and the frontier, with no new research. Input: the objective,notes.md (verified claims, datapoints, sources, contradictions, assumptions and gaps) and the frontier summary including unresolvable and pruned questions with reasons. Tools: notes_read, frontier_view, and finalize_report(report). It may not search; if it finds a hole it cannot fill it must state it in the report (a hole that would change the conclusion may reopen exploration once: finalize_report can return to exploration with a reopened question, at most max_reopens times, default 1).
The report has the same shape as today (summary first, then details, then the source list) plus a section on gaps and assumptions taken from unresolvable questions, and the verification status of key claims. Every statement is traceable to claim and source ids, rendered as URLs.
finalize_report is the completion tool. It is the only normal way for a run to complete. It is called only in the synthesis loop; the text passed is the report stored in gb_agent_research_runs.report.
Finalization gate
Deterministic checks in code, returned to the agent as an error listing what is missing when they fail: Exploration gate (finish_exploration):
- the root question is
resolved, orunresolvablewith a stated assumption, - no
pendingquestion withpriority >= gate_priority(default 0.5) that is actionable, - at least
min_sources(default 5, scaled down by the run options for narrow tasks) distinct sources in notes, from at leastmin_domains(default 3) different domains, - every
resolvedquestion cites at least oneVERIFIEDclaim, or explains (stored) whyUNVERIFIEDis the best available, - no unaddressed entry in
Contradictions, - no
openchallenge, and theconclusionhas a closed challenge whose outcome issurvived(afterweakenedorrefutedthe exploration goes on).
finalize_report): the report cites only existing source ids; every unresolvable question appears under gaps; nothing in the report contradicts a REFUTED claim (checked by claim-id references, not by text analysis).
Circuit-breakers
Checked by code before every model call; none depends on the model’s cooperation.
When a breaker fires during exploration, the loop does not fail the run: it moves directly to synthesis, with a note in the notes file and in the report that the investigation was cut short and by what (
breaker: cost), listing the still-pending questions as open gaps. Synthesis has its own reserved budget (synthesis_reserve, default 15% of the iteration/token/cost limits) that exploration cannot spend, so a report can always be written. If even the reserve is exhausted, the run ends as failed with the breaker named in error (today’s behaviour for an agent that ends without a report). The last turn is always forced to be finalize_report (tool_choice on that tool), replacing today’s “last step has no tools”.
Parallelism and sub-agents
The frontier is the unit of work distribution. With one worker the loop selects one question at a time. The design allows more: several workers (the existing sub-agents) can each take anin_progress question; selection and status changes are atomic in the DB (a question is claimed with a conditional update, pending -> in_progress with the worker’s run id), and max_parallel bounds them. The sub-agents of a run share its frontier, notes and claim ledger; spawn_subagent is called with a question_id so the sub-agent’s task is that node, its report resolves it. How sub-agents write to the shared notes (serialized appends through the same tools) is an implementation matter; the first implementation may run with max_parallel = 1 and leave this off.
Database schema
New tables, ininternal/agent_research/db/{sqlite,postgres}/schema.sql (final state, idempotent, same tables in both, columns documented in the SQLite file) and listed in docs/listings/db-tables.md. All are scoped to a run and deleted with it (ON DELETE CASCADE). The frontier is not stored in notes.md. The notes file is a working copy on disk; its history is gb_agent_notes_versions. The claim/source tables below are the structured history behind the notes sections.
Migrations. Every startup applies the schema to the database the server opens (the Open of the package, internal/agent_research/ops.go):
- A new table is a
CREATE TABLE IF NOT EXISTSinschema.sql. It is created on start and needs nothing else. - A change to an existing table (a new column, a new index) is an
ALTERin the package’s upgrade list, run on every start.CREATE TABLE IF NOT EXISTSnever changes a table that already exists, so theschema.sqlfile alone is not enough for an older database. SQLite has noADD COLUMN IF NOT EXISTS: eachALTER ... ADD COLUMNruns only whenpragma_table_infodoes not list the column. PostgreSQL usesADD COLUMN IF NOT EXISTS. The same column also goes intoschema.sql, so a new database gets it directly. - Indexes on an added column are created after the upgrade (as
schema_subagent_indexes.sqlis). - A column added by
ALTERmay have a simpleCHECK. Changing an existingCHECKneeds a table rebuild in SQLite (themigrateSQLiteStatuspattern), so avoid it.
notes.md; the file is the rendered view, these tables let the gates and the exports work without parsing text):
gb_agent_challengesis created aftergb_agent_frontier_attempts, and itsid(‘ch_001’) is assigned like the others.outcome = 'refuted'and'weakened'require rows ingb_agent_challenge_sources(checked by the code that closes the challenge). In PostgreSQLattempt_idisBIGINT.VERIFIEDis only reachable whengb_agent_claim_sourceshas at leastmin_independent_sourcessupportsrows with distinctdomains and at leastmin_challengesclosed challenges with outcomesurvivedon the claim (checked by the code that updates the status, not by a DB constraint).- Question ids and claim ids are assigned by the code from the per-run counters (max existing id + 1) in the same transaction as the insert.
- The existing
gb_agent_research_statesgets newstatevalues (not constrained):planning(broad expansion),selecting,extracting,verifying,challenging,consolidating,synthesizing. gb_agent_research_runsgets three columns, all byALTER(see Migrations):notes_id(the random id of the notes file,''for runs made before),phase(exploration,synthesis) andstop_reason(finalized,frontier_exhausted,breaker:iterations,breaker:tokens,breaker:cost,''while running).notes.md(the working copy) is a file per run, named as in Working state. Its history isgb_agent_notes_versions:
- The versions of a run are kept until the run is deleted. Consolidation keeps each copy near
notes_max_tokens, so the table grows by about one such copy per write.
Observability
Each iteration is a recorded state with the question id in itsdetail, as today. The admin UI and the ATIF trajectory can render the frontier as a tree (parent_id) with statuses, from the tables above; the transitions table gives the history of every question, one row per status change, so the path of the search (what was selected when, what was retried, reopened or pruned and why) can be replayed and charted; typical reads: time spent per status, questions that were reopened, resolved-vs-unresolvable ratio per depth, what the code (versus the agent) pruned. The challenges table is the record of how the agent tests itself; typical reads: challenges per target and per strategy, outcome distribution (a run where every challenge survived with one strategy is suspect), refutations with their sources, and for each refuted/weakened outcome the claims and questions that changed afterwards (result_claim_id, gb_agent_frontier_transitions, where caused_by holds the challenge id). Each challenge is also a state (challenging) of the run. Totals (steps, tool_calls, tokens, cost) keep their meaning; steps now counts turns.
Configuration
New knobs, in the agent definition and as run options (with these defaults); none changes the behaviour of an agent definition that does not set them exceptloop = "investigative", which turns the whole design on (the existing plain loop remains as loop = "simple" during the migration):
research_notes_dir (server config, default in the research data directory) · max_depth 3 · max_attempts 4 · max_questions 40 · max_iterations 60 · max_total_tokens · max_cost_usd · synthesis_reserve 0.15 · keep_turns 2 · extraction_threshold 4000 chars · notes_max_tokens 6000 · min_sources 5 · min_domains 3 · min_independent_sources 2 · min_challenges 1 · max_challenges_per_target 3 · gate_priority 0.5 · max_reopens 1 · max_parallel 1.
Out of scope (for the first implementation)
- Notes in the DB or an object store (the interface allows it).
- More than one worker on the frontier.
- Semantic dedup of questions with embeddings (a normalized string comparison first).
- Automatic assessment of source independence beyond domain and the agent’s declaration.
- Changes to
web_search’s own server-side behaviour.
Delivery order
The steps below are implemented in two stages. Stage 1 covers the pure core, the schema and store, and the frontier, notes and challenge tools, with no change to the loop. Stage 2 covers the investigative loop, the gates, the breakers, the observability, the knobs and the evals. Each stage page says what to build and how to know it is done.- Pure core: frontier, claim and notes values and transitions; selection, pruning, gates, breaker checks (with tests, no model).
- Schema (both backends), store functions,
docs/listings/db-tables.md. - Tools (frontier, notes,
fetch_page,finish_exploration,finalize_report) and the effect layer. - The exploration loop with prompt rebuilding and eviction, then the synthesis loop.
- Breakers and reserve, states, observability, agent-definition knobs.
- Evals: compare against the
simpleloop on the same topics (cost, number of verified claims, gaps reported).