Feature: KB Research & Synthesis Agent
Last updated: 2026-06-24 (docs-gap backfill for #1336; feature shipped in #1279, closes #1225)
The KB Research & Synthesis agent bootstraps a new client's Knowledge Base
during onboarding. After an Account Manager (AM) seeds a handful of inputs
(files, URLs, free-form prompts), the agent reads them, sleuths a few public
sources, and synthesizes four first-draft KB markdown documents the
Specialist can eventually retrieve. Every synthesized document is written
pending_review and is hidden from Specialist retrieval until an AM or
SuperAdmin approves it โ that is the retrieval safety gate, and it is the
highest-risk part of the feature.
Default-off / hidden-by-default. Unapproved synthesis can never influence a Specialist's drafted reply. The retrieval gate (below) is enforced in
HaystackRetrievalServiceโ serving onlyapproved(or legacyNULL) documents. Do not weaken it. (See Retrieval safety gate for the four hidden-status cases and the current test coverage.)
This feature sits between two surfaces:
- AM seed inputs (#1224 / #1277) โ what the AM uploads before research runs.
- KB retrieval (ADR-008) โ how approved documents reach the Specialist at chat time.
End-to-end flowโ
AM seeds inputs Research agent (Python) Review gate (NestJS) Specialist
โโโโโโโโโโโโโโโโโ โโโโโโโโโโโโโโโโโโโโโโโ โโโโโโโโโโโโโโโโโโโโ โโโโโโโโโโ
POST /admin/orgs/:orgId POST /v1/kb/research 4 docs written KB retrieval
/seed-kb โโ render AM inputs status = pending_review (search)
files / urls / prompts โโ fetch public context โโโบ source_tag = agent_synthesis โโโบ gate filters
โ โ (X profile, homepage, synthesis_run_id = <uuid> status != approved
โผ โ DuckDuckGo searches) โ โ
source_tag = โโ Haiku 4.5 synthesis AM/SuperAdmin reviews: only approved
am_file/am_url/am_prompt โโ validate (4 docs + Approve / Edit / Reject docs served
โ citations footer) โ
โโโโโโโโโโโโโโ am_inputs โโโโโโโโโโโโโบ โผ
status = approved โโโโโโโโโโโโโโโบ
- Seed. The AM submits an input bundle for the org (see AM-input bundle). Each input is stored on
org_documentstaggedam_file,am_url, oram_prompt. - Run. The research run is invoked with the org's
am_inputs. The agent gathers public context, calls the LLM, and returns exactly four documents. - Persist. The four documents are written to the org KB with
status = 'pending_review',source_tag = 'agent_synthesis', and a sharedsynthesis_run_id. - Review. An AM (assigned to the org) or a SuperAdmin previews each draft and Approves, Edits & Approves, or Rejects it.
- Serve. Only
approveddocuments pass the retrieval gate and become visible to the Specialist.
The research agent (Python / FastAPI)โ
| Item | Value |
|---|---|
| Endpoint | POST /v1/kb/research (auth: verify_agent_secret) โ the router defines /kb/research and agent/main.py mounts it under the /v1 prefix |
| Router | agent/routers/kb_research.py |
| Pipeline | agent/research_synthesis.py โ synthesize_research_docs() |
| Models | agent/models/research.py โ KbResearchRequest / KbResearchResponse |
| Default model | claude-haiku-4-5 (override with env KB_RESEARCH_MODEL or request.model) |
Request / response shapeโ
// KbResearchRequest
{
"orgId": "โฆ", // client org UUID (alias: org_id)
"clientName": "Acme Financial",
"am_inputs": [ // the AM-seeded bundle (see below)
{ "docId": "โฆ", "source": "am_url", "title": "Acme homepage",
"snippet": "https://acme.example โฆ about us โฆ" }
],
"budget_cap_tokens": 200000, // default 200K
"model": null // optional override
}
// KbResearchResponse
{
"synthesis_run_id": "โฆ", // groups the four output docs
"budget_exceeded": false,
"documents": [ { "filename": "โฆ", "title": "โฆ", "content": "โฆ" }, โฆ ],
"consumed_tokens": 41200
}
Public-source sleuthingโ
gather_public_context() augments the AM corpus with a bounded set of public
fetches:
- The client's X/Twitter profile (
https://x.com/<handle>, handle derived fromclientName). - The homepage, extracted from the first URL found in the AM inputs (
homepage_from_inputs). - Two DuckDuckGo HTML searches:
"<client>" tone OR voice OR brandand"<client>" compliance OR regulatory.
All outbound fetches are SSRF-guarded by _hostname_is_safe(), which rejects
cloud metadata/IMDS endpoints (169.254.169.254, metadata.google.internal,
Alibaba 100.100.100.200, AWS IMDSv2 IPv6, โฆ) and any non-global IP. The final
host is re-validated after redirects, so an attacker-controlled 302 to an
IMDS endpoint cannot leak instance metadata into a synthesized document.
The four output documentsโ
validate_documents() enforces that the LLM returns exactly these four
filenames, each ending with a ---\n## Citations footer:
| Filename | Contents |
|---|---|
brand-voice-playbook.md | Tone guide. Golden examples + anti-examples โ every example must carry a citation id ([doc:โฆ], [url:โฆ], or [search:โฆ]). |
product-context.md | Products, audience, status. |
compliance-notes.md | Flagged risk topics โ only echoed from the corpus, never invented. |
key-contacts-extracted.md | Names, roles, escalation paths. |
If the LLM returns the wrong number of documents, a wrong filename, a missing citations footer, or an uncited brand-voice example, synthesis raises rather than persisting a malformed draft.
Cost discipline (atomic budget stop)โ
FetchBudget enforces two hard caps per run:
- 30 external fetches (
max_fetches). - A token budget (
budget_cap_tokens, default 200,000). Tokens are charged for the AM corpus, every public-context block, the synthesis prompt, and the LLM response.
The moment the budget is exceeded, mark_exceeded() emits the
kb_research_budget_exceeded Prometheus counter (labelled by org) and the run
returns budget_exceeded: true with an empty documents array โ an
atomic stop with no partial writes. The NestJS side persists nothing for a
budget-exceeded run.
Retrieval safety gate (NestJS)โ
This is the critical invariant of the feature. Unapproved synthesis must never reach the Specialist. The gate lives in
api/src/haystack/haystack-retrieval.service.ts.
Specialist retrieval flows through two methods, both status-gated:
search()โ production retrieval. Candidate chunks are filtered throughallowedDocumentIds(), whoseWHEREonly admits documents withstatus = 'approved'orstatus IS NULL(legacy rows that predate the review states).pending_review,rejected, andsupersededare excluded. The same predicate also enforceseffective_date <= today, audience, andparse_status IN ('ingested','parsed').listDocumentChunks()โ fetches the chunks, then re-reads the document'sstatusand returns[]whenever it is set to anything other thanapproved.
status Specialist retrieval
โโโโโโโโโโโ โโโโโโโโโโโโโโโโโโโโ
approved โ
served
NULL โ
served (legacy, pre-review rows)
pending_review โ hidden
rejected โ hidden
superseded โ hidden
The gate's contract is its code: allowedDocumentIds() (for search()) and
the status re-check in listDocumentChunks(). The review-state transitions that
feed it (approve / reject / and the refusal to act on a superseded doc) are
covered by api/src/kb/kb-documents.controller.spec.ts.
A dedicated retrieval-gate spec asserting the four hidden-status cases against
HaystackRetrievalService is not currently present in dev (it was named
ragflow-retrieval-status-gating.spec.ts in #1279 but did not survive the
RAGFlow โ Haystack port โ see Implementation status).
Re-adding it before any change that touches the gate is the safest guard.
Admin-inspection bypassโ
AMs need to preview and edit a pending_review draft, which the Specialist
gate would otherwise return empty. KbService.getDocument() therefore routes
HP review roles (superadmin, account_manager, expert) to
listDocumentChunksForReview(), which bypasses the status gate for human
inspection only. This bypass never touches search() โ the Specialist's
retrieval path is unaffected.
Known scope note.
expertis included in that review-role set, so an org expert can read pending synthesis chunks via the document-detail endpoint. This is human read-only access; it does not affect the Specialist AI gate. Tracked as a P2 observation on #1279.
Review workflow & document statesโ
Synthesized documents reuse the org_documents.status column as their review
state (OrgDocumentReviewStatus):
| Status | Meaning | Specialist sees it? |
|---|---|---|
pending_review | Freshly synthesized draft, awaiting review | No |
approved | AM/SuperAdmin approved (or edited & approved) | Yes |
rejected | AM/SuperAdmin rejected; excluded from Specialist context | No |
superseded | Replaced by a newer document/run | No |
Review endpointsโ
Exposed by KbAdminController under admin/kb, restricted to superadmin and account_manager:
| Endpoint | Action |
|---|---|
POST /admin/kb/documents/:id/approve | status โ approved; stamps approved_by / approved_at. |
POST /admin/kb/documents/:id/reject | status โ rejected; stamps rejected_by / rejected_at. |
POST /admin/kb/documents/:id/edit | Replaces the document content (body { content }, validated non-empty), re-ingests, then status โ approved; stamps edited_by. |
Authorization is enforced in KbService.findDocumentForReview():
- Caller must be
superadminoraccount_manager. - An
account_managermust be the org's assigned AM (organizations.assigned_am_id). - The transition is gated on the document still being
pending_reviewโ a concurrent approve/reject returns HTTP 409 (ConflictException) rather than double-applying, per the platform's idempotent state-machine contract.
Every transition writes an audit-log entry (kb.document.approved /
kb.document.rejected / kb.document.edited_approved) carrying the
synthesisRunId and sourceTag.
Review surface (Ops)โ
The Ops client-detail page (/ops/clients/[id]) renders a "Pending
agent-drafted KB documents" panel (PendingAgentKbPanel) for AMs and
SuperAdmins, with a preview modal, Approve / Edit / Reject actions, and a button
to trigger a new research run.
The panel โ and its "Run research" button โ is hidden on a fresh org.
PendingAgentKbPanelreturnsnullwhen the org has nopending_reviewand norejectedagent-synthesis documents, so it only appears once a run has already produced drafts. Combined with the orchestration seam noted in Implementation status, the first run for a brand-new org is not currently launchable from this panel.
AM-input bundle (#1224 / #1277)โ
Research runs over inputs the AM seeds before synthesis. The bundle is
submitted to KbSeedingController:
POST /admin/orgs/:orgId/seed-kb (superadmin | account_manager)
multipart: files[] (โค 20 files, โค 25 MB each)
payload: { urls: string[], prompts: string[] }
GET /admin/orgs/:orgId/seed-kb/inputs โ list seeded inputs
Each accepted input becomes an org_documents row tagged with its source, and
is passed to the agent as an AmResearchInput:
source tag | Origin | How the agent uses it |
|---|---|---|
am_file | Uploaded file | Rendered into the # AM inputs corpus, cited as [doc:<docId>]. |
am_url | URL provided by the AM | Same; the first URL also seeds the homepage fetch. |
am_prompt | Free-form AM instruction | Same; steers tone/compliance emphasis. |
render_am_inputs() formats the bundle into the corpus the synthesizer reads,
and homepage_from_inputs() extracts the first http(s) URL as the homepage to
fetch. AM inputs are themselves readable by reviewers via the unchecked
admin-inspection path (listAmInputChunks), independent of the Specialist gate.
Schemaโ
Added by migration
1780291000000-OrgDocumentResearchReviewFields.ts:
| Column | Notes |
|---|---|
org_documents.status | Default changed to approved. Review state: pending_review | approved | rejected | superseded. |
org_documents.synthesis_run_id (uuid, nullable) | Groups the four agent_synthesis drafts from one run. |
org_documents.source_tag (text, nullable) | am_file | am_url | am_prompt | agent_synthesis. |
Indexes: idx_org_documents_approved_audience (partial, status='approved' โ
the retrieval hot path), idx_org_documents_pending_review (the AM review
queue), and idx_org_documents_synthesis_run.
The migration remaps legacy statuses forward (active โ approved,
draft โ pending_review, deprecated โ superseded; unknown values fall back to
approved). Risk: any pre-existing document not in active becomes
non-approved and therefore excluded from retrieval after the migration โ by
design, since the gate now serves approved/NULL only.
Relevant configโ
| Variable | Service | Default | Purpose |
|---|---|---|---|
KB_RESEARCH_MODEL | agent | claude-haiku-4-5 | Synthesis model. |
budget_cap_tokens (request field) | agent | 200000 | Per-run token budget. |
Implementation status (as of 2026-06-24, dev)โ
The feature spans two services joined by an orchestration seam. Current state on
dev:
- Agent service โ the synthesis pipeline (
POST /kb/research,research_synthesis.py, the four-document + citation validators, the budget and SSRF guards) is present. - API service โ the review/retrieval gate (the
statusenum + migration, theHaystackRetrievalServicegate, the approve/reject/edit endpoints, the AM seed-input intake) is present. - Orchestration seam โ
#1279landed the NestJS trigger as a RAGFlow-named BullMQ worker +POST /admin/clients/:orgId/research:runhook. That worker was not carried through the RAGFlow โ Haystack migration (ADR-024); the Ops "Run research" button (runClientKbResearch) still posts toโฆ/research:run, which is the integration point a Haystack-side processor needs to re-expose to wire the two halves together end-to-end.
This is worth knowing before assuming a single button click drives the whole pipeline today: the agent pipeline and the review gate are independently shipped and tested; the on-demand trigger that bridges them is the remaining seam.
Related docsโ
- ADR-008 โ Knowledge Base architecture (K1 + K2) โ KB scope/ACL primitive the synthesized docs live under.
- ADR-024 โ Haystack migration โ current retrieval backend (RAGflow โ Haystack/Hayhooks).
- Onboarding โ where AM seeding fits in the client setup flow.
- Agentic Tasks โ the Specialist-side agent runtime that consumes approved KB context.
- Expert Queue (HITL) โ the human-in-the-loop review model this gate mirrors.