Skip to main content

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 only approved (or legacy NULL) 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 โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ–บ
  1. Seed. The AM submits an input bundle for the org (see AM-input bundle). Each input is stored on org_documents tagged am_file, am_url, or am_prompt.
  2. 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.
  3. Persist. The four documents are written to the org KB with status = 'pending_review', source_tag = 'agent_synthesis', and a shared synthesis_run_id.
  4. Review. An AM (assigned to the org) or a SuperAdmin previews each draft and Approves, Edits & Approves, or Rejects it.
  5. Serve. Only approved documents pass the retrieval gate and become visible to the Specialist.

The research agent (Python / FastAPI)โ€‹

ItemValue
EndpointPOST /v1/kb/research (auth: verify_agent_secret) โ€” the router defines /kb/research and agent/main.py mounts it under the /v1 prefix
Routeragent/routers/kb_research.py
Pipelineagent/research_synthesis.py โ€” synthesize_research_docs()
Modelsagent/models/research.py โ€” KbResearchRequest / KbResearchResponse
Default modelclaude-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 from clientName).
  • The homepage, extracted from the first URL found in the AM inputs (homepage_from_inputs).
  • Two DuckDuckGo HTML searches: "<client>" tone OR voice OR brand and "<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:

FilenameContents
brand-voice-playbook.mdTone guide. Golden examples + anti-examples โ€” every example must carry a citation id ([doc:โ€ฆ], [url:โ€ฆ], or [search:โ€ฆ]).
product-context.mdProducts, audience, status.
compliance-notes.mdFlagged risk topics โ€” only echoed from the corpus, never invented.
key-contacts-extracted.mdNames, 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 through allowedDocumentIds(), whose WHERE only admits documents with status = 'approved' or status IS NULL (legacy rows that predate the review states). pending_review, rejected, and superseded are excluded. The same predicate also enforces effective_date <= today, audience, and parse_status IN ('ingested','parsed').
  • listDocumentChunks() โ€” fetches the chunks, then re-reads the document's status and returns [] whenever it is set to anything other than approved.
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. expert is 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):

StatusMeaningSpecialist sees it?
pending_reviewFreshly synthesized draft, awaiting reviewNo
approvedAM/SuperAdmin approved (or edited & approved)Yes
rejectedAM/SuperAdmin rejected; excluded from Specialist contextNo
supersededReplaced by a newer document/runNo

Review endpointsโ€‹

Exposed by KbAdminController under admin/kb, restricted to superadmin and account_manager:

EndpointAction
POST /admin/kb/documents/:id/approvestatus โ†’ approved; stamps approved_by / approved_at.
POST /admin/kb/documents/:id/rejectstatus โ†’ rejected; stamps rejected_by / rejected_at.
POST /admin/kb/documents/:id/editReplaces 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 superadmin or account_manager.
  • An account_manager must 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. PendingAgentKbPanel returns null when the org has no pending_review and no rejected agent-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 tagOriginHow the agent uses it
am_fileUploaded fileRendered into the # AM inputs corpus, cited as [doc:<docId>].
am_urlURL provided by the AMSame; the first URL also seeds the homepage fetch.
am_promptFree-form AM instructionSame; 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:

ColumnNotes
org_documents.statusDefault 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โ€‹

VariableServiceDefaultPurpose
KB_RESEARCH_MODELagentclaude-haiku-4-5Synthesis model.
budget_cap_tokens (request field)agent200000Per-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 status enum + migration, the HaystackRetrievalService gate, the approve/reject/edit endpoints, the AM seed-input intake) is present.
  • Orchestration seam โ€” #1279 landed the NestJS trigger as a RAGFlow-named BullMQ worker + POST /admin/clients/:orgId/research:run hook. 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.