I wanted a research agent that could investigate an engineering question, follow up when a source was incomplete, and leave enough evidence for me to check its conclusions.
Getting a model to search and write an answer is straightforward. The difficulty is knowing whether it investigated the right questions, whether its citations support its claims, and when it should stop. A convincing paragraph tells us very little about that work.
So I built a research agent with Microsoft Foundry, Serper, and PostgreSQL. The model chooses what to investigate next. Our C# application checks its actions, records the evidence, and decides whether the investigation can finish.
Start With a Question Worth Investigating
The sample asks whether HybridCache can replace Redis in a multi-instance .NET application. I chose this because an answer needs to distinguish cache responsibilities from invalidation across application instances. We make those two questions explicit in the brief:
{
"question": "Can HybridCache replace Redis in a multi-instance .NET application?",
"questions": [
{
"id": "Q1",
"text": "Which responsibilities belong to HybridCache and which belong to a distributed cache backend?"
},
{
"id": "Q2",
"text": "What are the documented invalidation limitations across application instances?"
}
],
"allowedHosts": [
"learn.microsoft.com",
"github.com",
"raw.githubusercontent.com"
],
"seedUrls": []
}The default has no seed URLs, so the agent must discover pages before reading them. Allowed hosts define its source scope. You can supply seeds in a custom brief; the agent can still search, but may finish without searching if the supplied material answers the questions and meets the evidence requirements.
The Architecture: One Agent and a C# Host
I kept the architecture to one agent loop managed by a C# host. Foundry proposes one native function call per turn through its chat-completions API. The host validates the call, executes it, and commits the result before asking for another decision.
SearchWeb and ReadPage cross the web-service boundary. RecordEvidence and FinishResearch run inside the host. Invalid tool arguments return an error the model can correct, while still consuming a step.
Run It Against Your Own Services
You need .NET 10, an existing PostgreSQL database, a Foundry deployment supporting function calling, and Foundry and Serper API keys. From the companion directory, set these values and run:
$env:RESEARCH_Database__ConnectionString = 'Host=localhost;Port=5432;Database=research;Username=postgres;Password=YOUR-PASSWORD'
$env:RESEARCH_Foundry__Endpoint = 'https://YOUR-RESOURCE.openai.azure.com/openai/v1/'
$env:RESEARCH_Foundry__Deployment = 'YOUR-DEPLOYMENT'
$env:RESEARCH_FOUNDRY_API_KEY = 'YOUR-FOUNDRY-KEY'
$env:RESEARCH_SERPER_API_KEY = 'YOUR-SERPER-KEY'
dotnet run --project BoundedResearchAgent -- init-db
dotnet run --project BoundedResearchAgent -- runinit-db creates the research tables inside your database. Use the Foundry inference base URL ending in /openai/v1/ and the actual deployment name. The README also includes a small PostgreSQL Compose setup if you need a local database.
Follow the Missing Evidence
If a page answers Q1 but leaves Q2 unresolved, the agent can inspect another section, choose another result, or refine its query. Serper provides candidate links; ReadPage fetches the selected page. Reads require a seed or search-discovered URL on an allowed HTTPS host. Redirects pass the same host check.
I wanted the agent to investigate beyond the first visible paragraph. Each fetched source is stored as a bounded snapshot; the model receives a smaller text window with passage IDs and section offsets. It can request later sections using find or offset without downloading the page again.
To record evidence, the model submits a question ID, source ID, claim, and supporting or contradictory passage. Passing an excerptId lets the host copy the exact stored quotation. Free-form quotations must match the source verbatim. Search snippets alone cannot become evidence.
This gives us traceable quotations. We still need to judge whether they support the findings. For this question, a passage about stampede coordination cannot establish invalidation behavior. A compound claim needs evidence for every clause, even when its citation IDs are valid.
Keep the Investigation in PostgreSQL
I wanted to inspect how an answer was reached after the process had exited. PostgreSQL stores the resumable conversation and counters in a JSONB checkpoint, alongside relational source, evidence, and tool-step records. The citation references let you trace a finding back to its stored source:
You can follow those references with SQL:
SELECT e.question_id, e.claim, e.quote, s.url
FROM research.evidence e
JOIN research.sources s USING (run_id, source_id)
WHERE e.run_id = 'REPLACE-WITH-RUN-UUID'
ORDER BY e.evidence_id;Each checkpoint commits the updated run and its new records in one transaction. Foreign keys protect evidence-to-source relationships, version checks reject stale writes, and a per-run advisory lock excludes competing CLI workers.
The CLI can resume an interrupted running investigation. For runs that reached FinishResearch, replay executes the captured decisions without provider calls, writes a separate run, and compares the report and status. Resume retains the original limits and deadline; terminal runs stay terminal. A crash between an external response and its database commit can still repeat that uncommitted request and its cost.
Make Completion an Explicit Decision
Calling FinishResearch asks the host to check the report. Every finding must cite recorded evidence. Those citations must cover every required question and, by default, at least two distinct source URLs. This part of the check identifies questions missing from the cited evidence:
var cited = report.Findings
.SelectMany(f => f.EvidenceIds)
.ToHashSet(StringComparer.Ordinal);
var missing = run.Brief.Questions
.Where(q => !run.Evidence.Any(
e => e.QuestionId == q.Id && cited.Contains(e.Id)))
.Select(q => $"{q.Id}: {q.Text}")
.ToList();A premature submission returns the gaps and unread URLs so the agent can continue. After three unsuccessful submissions, unresolved required questions leave the run incomplete. Optional deployment choices belong in limitations and do not block completion.
I capped the default run at 20 steps, four searches, six page-fetch attempts, five minutes, and 100,000 provider-reported tokens. Search and page caps block further network requests. Step, time, token, and context limits can end the run as incomplete. Token usage arrives after each model response, so one request can cross the threshold. Older page windows are compacted for subsequent requests while the full audit transcript stays in PostgreSQL.
The source minimum counts distinct URLs. Pages copied from the same underlying document can still pass it, so source independence needs separate review.
A Live Run Without Seed URLs
I ran the question-only brief against Foundry and Serper with an empty seed list. The investigation completed in about 23 seconds:
Run: 8e645b6b-3fd3-428b-8b8e-674c67ee82fb
Status: completed | Stop: coverage_gate_passed
Steps: 12 | Searches: 1 | Pages: 3 | Evidence: 4
Reported tokens: 70967The first Serper query returned five URLs. The agent fetched Microsoft's HybridCache, distributed-cache, and .NET caching documentation, then cited two of those pages in its findings. It also revisited two cached sections without another page fetch.
At step eight, the host rejected FinishResearch because the report cited only one source. It returned the missing source requirement and unread URLs. The agent read another discovered page, recorded more evidence, and passed the coverage gate on its second submission at step twelve.
The checkpoint, relational records, and exported report passed 111 consistency checks. Offline replay reproduced the final report without provider calls or database writes. This execution shows discovery, further investigation after host feedback, and persisted results.
Review still found a citation weakness: the stampede-protection finding cited E001 and E003, while explicit support was recorded in E002. I treat completion as a coverage result and check each finding against its cited passages before relying on the answer.
Test the Decisions the Application Owns
Validation passed 76 tests, including real PostgreSQL integration checks. They cover source discovery, invented quotations and citations, incomplete coverage, execution limits, transaction rollback, competing workers, and historical replay. Set RESEARCH_TEST_POSTGRES to a dedicated test database to include the database checks; the README explains the required permissions.
dotnet test BoundedResearchAgent.slnxThese tests check the host's behaviour. Research quality still requires reviewing claim support, source independence, version scope, and unresolved contradictions.
What I Would Add Next
I would start with a human-labelled evaluation set and source-family tracking, so mirrored documentation does not look like independent confirmation. PDF extraction would widen the available material. A reviewer model would be worth adding once measured failures justify another model call.
Final Notes
The useful part of this agent is its ability to pursue an unanswered question. The evidence ledger lets us examine whether that pursuit produced a defensible answer.
You can run the example, change the brief, and inspect the resulting claims and sources.
Explore the source code in the GitHub repository.
See you in the next issue.
Stay curious.
