Deep-research agent: chat-triggered long-running corpus research with citations#1814
Conversation
…with citations New app `opencontractserver/research/` introduces a deep-research agent: a user in a corpus chat can ask the assistant to "research X" and a long-lived (5-30 min) autonomous job kicks off in the background. The job runs a PydanticAI corpus agent with a strictly read-only retrieval surface plus two job-bound scratchpad tools (`record_finding` / `finalize_report`), produces a markdown report with footnote citations, and notifies the user when done. Key invariant: the closed citation graph. `record_finding` validates every `supporting_source_id` against `PydanticAIDependencies.retrieved_annotation_ids` (populated by retrieval tools during the run). Annotation IDs not surfaced by a retrieval tool are rejected with an error string, defending against fabricated citations. v1 scope: creator-only visibility (no sharing), corpus-wide (no doc subset), cooperative cancel via `cancel_requested` polled between tool calls. Plumbing changes (small, additive): - `restrict_tool_names` plumbed through the corpus agent path (already worked for the document path). - `PydanticAIDependencies.conversation_id` + inject helper now thread the active conversation into tools that declare it. GraphQL: `ResearchReportType`, `researchReport(id)` / `researchReports`, `startResearchReport` / `cancelResearchReport` mutations. Tests: model + service + kickoff-tool coverage. Stub-agent integration test for the Celery task is intentionally deferred (would require docker). See CHANGELOG entry for full details and v2 follow-ups.
Code ReviewThis is a substantial and well-architected feature — the PR description is thorough, the service-layer discipline is sound, and the closed-citation-graph invariant is a clever design. Below are findings organised by severity. Must-Fix (Blocking)1.
2. Missing migration for Adding 3. The Celery 4.
5.
Should-Fix (Non-Blocking but Important)6. Race condition in concurrency guard In 7. No ceiling on user-supplied
8. Double-write in salvage path In 9. Missing The list resolver 10. Unlimited Neither the GraphQL mutation nor 11. Status filter not validated in
Test Coverage Gaps
Minor / Cleanup
What's Working WellThe design of the closed-citation-graph invariant (enforced at both |
…vqbFb # Conflicts: # CHANGELOG.md
CI failures fixed: - test_types: TestJobStatus now expects 6 members (CANCELLED added) - test_research_report_service: wrap start() in captureOnCommitCallbacks so the on_commit-deferred Celery delay fires inside a TestCase - New migration analyzer/0022_alter_analysis_status for the JobStatus choices delta on Analysis.status (CANCELLED added) Must-fix correctness: - Drop ask_document from DEEP_RESEARCH_READ_ONLY_TOOLS until its sub-agent sources are wired into retrieved_annotation_ids. Citing an annotation seen only via ask_document was being silently rejected by the closed-citation-graph invariant. - SoftTimeLimitExceeded now routes through mark_cancelled (with a warning) instead of mark_failed, preserving partial findings and emitting a RESEARCH_REPORT_CANCELLED notification. - mark_cancelled gains an optional warning kwarg so the timeout path can surface why the run stopped. - Drop AnnotatePermissionsForReadMixin from ResearchReportType (no guardian tables exist on ResearchReport) and replace with an explicit creator-only resolve_my_permissions. The mixin used to silently return [] for the creator's own row. - Wrap from_global_id / int() in research_queries and research_mutations so malformed input returns the IDOR-safe "not found" branch instead of a 500. - Add @login_required on resolve_research_report (was inconsistent with the list resolver). - Validate the status filter on resolve_research_reports against JobStatus values to avoid silent empty results for bad input. Hardening: - Race-tight start(): single transaction.atomic() block + select_for_update() serialises the concurrency-guard check and the row insert. - Cap user-supplied max_steps to MAX_RESEARCH_STEPS_CEILING (500) and enforce 1 ≤ max_steps. - Cap user-supplied prompt at MAX_RESEARCH_PROMPT_CHARS (10k). - Pass warnings through finalize() instead of double-writing via a follow-up mark_completed in the salvage path. - DEFAULT_MAX_STEPS_FALLBACK constant replaces the magic 60 fallback in models.py and the service (CLAUDE.md rule 4). - Hoist `import re` from _render_citations to module level.
| "You are a deep-research analyst executing an autonomous, multi-step " | ||
| "investigation across a document corpus.", |
| "2. Each time you uncover a discrete, source-backed claim, call " | ||
| "`record_finding` with the claim text, the citing section, and the " | ||
| "annotation IDs returned by your retrieval tools.", |
| "3. When you have enough evidence to answer the task, call " | ||
| "`finalize_report` with an executive summary and the final markdown " | ||
| 'body. The body MUST use `<cite ids="a,b">claim text</cite>` ' | ||
| "placeholder tags for every cited claim — the system converts these " | ||
| "to footnote markers and a Sources section.", |
| "4. `finalize_report` is the terminal action. Once you call it, the " | ||
| "run ends.", |
| "- You MUST cite only annotation IDs that retrieval tools returned in " | ||
| "this run. Fabricated or guessed IDs will be rejected and you will " | ||
| "be asked to re-search.", |
| "- Do NOT speculate beyond what the corpus supports. If the corpus " | ||
| "does not contain the answer, say so explicitly in the report.", |
| "- Use `record_finding` liberally — it is your scratchpad. Findings " | ||
| "are persisted between tool calls and survive a worker crash.", |
| "- Prefer broad coverage early (vector + exact-text searches across " | ||
| "several queries), then drill into the most promising documents.", |
| "Begin by issuing 2–4 broad searches to map the corpus. Then " | ||
| "drill into the most promising documents and record findings as " | ||
| "you go. When you have a coherent answer, call `finalize_report`.", |
| import asyncio | ||
| import logging | ||
| import traceback | ||
| from typing import Any, Callable, cast |
Code Review: Deep-Research Agent (#1814)Overall: This is a substantial, well-thought-out feature. The architecture is sound — the service layer, closed-citation-graph invariant, and TOCTOU-safe concurrency guard are all well-executed. The issues below are mostly medium/low severity with one real bug, one CLAUDE.md policy violation, and a few design inconsistencies. CLAUDE.md Policy ViolationPR body credits Claude Code — CLAUDE.md rule: "Never credit Claude or Claude Code in commit messages, PR messages, comments, or any other artifacts." The PR description contains both: These should be removed before merging. Bug: Broken Chat-Message Link
body = (
f"Deep research **{status_label}**: *{report.title}*.\n\n"
f"[Open report](/research/{report.slug})"
)The PR explicitly lists "frontend ResearchReportView" as out of scope for v1. Every completion message will drop a broken link into the originating chat. Either omit the link for now, or substitute the corpus URL / a placeholder that users can act on. DRY Violation: Duplicated
|
Signed-off-by: JSIV <5049984+JSv4@users.noreply.github.com>
Code Review — PR #1814: Deep-research agentThis is a well-architected feature with thoughtful security design (closed-citation-graph invariant, prompt injection fencing, IDOR-safe mutations). The following items range from genuine bugs to minor style concerns. Bugs / Correctness
Time-limit constants evaluated at import time ( _SOFT_TIME_LIMIT, _HARD_TIME_LIMIT = _resolve_time_limits()
@shared_task(soft_time_limit=_SOFT_TIME_LIMIT, ...)
def run_deep_research(...):Because DRY Violation
Architecture / Conventions
def _load_user_and_corpus(user_id: int, corpus_id: int):
user = get_user_model().objects.filter(pk=user_id).first()
corpus = Corpus.objects.filter(pk=corpus_id).first() # direct ORM
return user, corpusCLAUDE.md rule 7 requires code with a user context to go through
deps_ref["deps"] = getattr(agent, "agent_deps", None)
Crashed-worker RUNNING jobs block the concurrency guard indefinitely
Performance
Test Coverage Gaps
Minor / Nits
SummaryThe feature is solid end-to-end: the closed-citation-graph invariant is the right design, the cooperative-cancel pattern is clean, and the service-layer structure follows project conventions. The main items to address before this sees heavy production traffic are the |
- graphql-api: add the backend's transient JobStatus.CREATED to the frontend enum; it was missing, so isTerminalResearchStatus(CREATED) wrongly returned true and stopped status polling before the job left the pre-queue state - researchUtils: treat CREATED as non-terminal and display it as 'Queued' - ResearchReportDetail: rename isRunning -> isActive (it covers created/queued/ running), key warning chips by their text instead of array index, log the swallowed error in the cancel catch block - StartResearchModal: log the swallowed error in the submit catch block - tests: cover CREATED in getResearchStatus and isTerminalResearchStatus
…at status tool (#1836) * Add deep-research chat status tool + slug query resolver Backend groundwork for the deep-research frontend (the v1 feature shipped backend-only; the completion chat message links to /research/{slug} but nothing resolved that route, and the chat agent could start a job but not report on one). - research_queries.py: add researchReportBySlug(slug) resolver (creator-only via BaseService.filter_visible; IDOR-safe null for non-owners/unknown slugs) so the frontend can resolve the /research/{slug} route. - research_tools.py: add check_deep_research_status chat tool (read-only) so a follow-up like "is my research done?" returns status/progress + the report link instead of being a dead end. - research_reports.py: add ResearchReportService.list_recent_for_corpus read helper (creator-only, bounded) backing the tool. - pydantic_ai_agents.py: register the status tool alongside the kickoff tool on the corpus chat agent; both are filtered out of the read-only research agent via restrict_tool_names. - tests: slug-resolver GraphQL tests + status-tool/service tests. * Build deep-research frontend: report view, corpus tab, notifications Completes the backend-only v1 (#1814) so users can monitor and read deep-research reports. The completion chat message linked to /research/{slug} but no route resolved it; RESEARCH_REPORT_* notifications weren't surfaced; and there was no report view or list. Frontend: - GraphQL + cache: research queries/mutations, JobStatus/ResearchReportType types, openedResearchReport reactive var, relayStylePagination keyed by the GraphQL field-arg names (corpusId/status) + researchReportBySlug keyArgs. - Routing: standalone /research/:slug resolved by CentralRouteManager Phase 1 (slug-based) with a Phase-3 canonical-redirect guard; openedResearchReport added to the routing write-discipline allowlist. - Views: ResearchReportDetail (SafeMarkdown body with footnote citations; Report/Citations/Sources/Run-details tabs; live polling + Cancel while running; citations deep-link to the cited annotation) and a corpus 'Research' tab listing reports. Secondary StartResearchModal for an explicit kickoff (primary trigger stays the chat agent). - Notifications: RESEARCH_REPORT_* wired into the notification type union + job-toast system (clickable, deep-links to /research/:slug), terminal cache updates, and a completion hook the detail view uses to refetch + stop polling. Tests: frontend unit tests (route parsing + research utils) and a Playwright component test for the detail view. Typescript compiles; unit + routing discipline tests pass. * Apply black formatting to research status-tool test * Remove unused loading binding from research detail useQuery CodeQL flagged the destructured `loading` from the GET_RESEARCH_REPORT useQuery as unused (the running/cancel states use the mutation's `cancelLoading`, not the query's). No behavior change. * Fix research detail CT crash: inline corpus back-URL The Playwright CT (esbuild dev) bundler failed to bind the named getCorpusUrl import in ResearchReportDetail, throwing 'getCorpusUrl is not defined' at render so the report never mounted (all 3 component tests failed). getDocumentUrl from the same module binds fine. Inline the corpus back-navigation URL (same /c/{creator}/{corpus} shape) and drop the getCorpusUrl import. The 3 ResearchReportDetail component tests now pass locally. * Fix research detail CT: mock had both variableMatcher and request.variables The real cause of the Component Tests failure: MockedProvider rejects a mock that supplies both request.variables and variableMatcher ("should contain either variableMatcher or request.variables"), so the provider constructor threw and the component never mounted. Keep the explicit request.variables ({ id }) form and drop the redundant variableMatcher. * Address review: client-side citation links, effect-guarded error toast, hook cleanups - ResearchReportDetail: RowLink is now styled(react-router Link) with to= so internal document/citation links route client-side instead of full-reload. - CorpusResearchReportCards: move the error toast into a useEffect so it fires once per error rather than on every re-render. - useResearchCompletionNotification: memoise numericId (feeds useCallback deps) and drop a leftover console.debug. - Add ResearchReportListCard component tests (completed + running) with a documentation screenshot. * Polish review items: color tokens, modal reset, Queued filter, cancel feedback Follow-up to the review fixes already on this branch: - ResearchReportDetail: replace remaining hardcoded hex (#ffffff/#fff7ed/ #9a3412) with OS_LEGAL_COLORS tokens (surface/warningSurface/warningText); stable keys (key={c.footnote} for citations, key=index+value for warnings); Cancel button shows 'Cancelling…' and disables while cancelRequested is pending. - StartResearchModal: reset prompt/title on close so a reopened modal starts blank. - ResearchTabContent: add the 'Queued' filter tab (backend status map already supports it; a fresh job sits in QUEUED briefly). Typescript compiles; research component tests pass. * Address review: clear openedResearchReport in all route handlers, restore getCorpusUrl helper, tokenize toast colors, reset research search on unmount - Clear openedResearchReport(null) in thread/labelset/user route handlers (sibling of openedExtract, was omitted) - Replace inlined corpus URL in ResearchReportDetail.handleBack with getCorpusUrl(corpus, {tab}); safer navigate('/') fallback - JobNotificationToast: source status colors from OS_LEGAL_COLORS tokens instead of raw hex - ResearchTabContent: reset researchSearchTerm and use DEBOUNCE.LIST_SEARCH_MS; clarify empty-state copy + add Load More in CorpusResearchReportCards - ResearchReportListCard: console.warn on missing slug instead of silent no-op * Add CorpusResearchReportCards smoke test + research report detail screenshot - New CorpusResearchReportCards.ct.tsx + wrapper: mounts the corpus Research tab list, asserts query wiring + empty state, captures a screenshot (review item 10). Populated-list assertion omitted — the component's network-only query re-fires across renders and MockedProvider can't serve it deterministically in isolation; card visuals are covered by ResearchReportListCard.ct.tsx. - ResearchReportDetail.ct.tsx: add docScreenshot for the completed-report state. * Address review: fix citation annotation typename, status type, toolbar token - Fix citation deep-link annotation typename mismatch in ResearchReportDetail: build the annotation global ID from fullSourceAnnotationList (the canonical ServerAnnotationType id the backend emits) via an annGlobalIdByPk map, instead of reconstructing toGlobalId('AnnotationType', pk) which guessed the wrong type name and produced deep-links resolving to the wrong entity. Drop the now-unused toGlobalId import. - Add a CT regression guard asserting the citation link's ?ann= param carries the ServerAnnotationType global id. - Type GetResearchReportsInput.status as JobStatus | undefined (was loose string) and tighten FILTER_TO_STATUS to match. - Use OS_LEGAL_COLORS.surface for the research Toolbar background instead of a hardcoded 'white' literal, consistent with the other toolbars in this PR. * Address review: seed ResearchReportDetail test reactive var synchronously ResearchReportDetailTestWrapper seeded openedResearchReport in a useEffect, which fires only after the child's first render — so ResearchReportDetail flashed its 'not found' state for one frame before the report appeared, a flakiness risk on slow CI. Switch to the synchronous useState-initializer pattern (matching CorpusResearchReportCardsTestWrapper) so the var is seeded before the first render, keeping a useEffect solely for unmount cleanup. * Address review: research-report routing, citation keys, double-fetch guard - CentralRouteManager: call routeLoading(false) before navigate('/404') in both the error and null-data branches of the research-report block. - ResearchReportRoute: remove dead error branch (CentralRouteManager redirects to /404 on error/null, never sets routeError for research). - ResearchReportDetail: key citation rows by array index, not c.footnote, to avoid React key collisions from duplicate footnotes in JSON. - StartResearchModal: navigate via getResearchReportUrl(payload.obj). - ResearchTabContent: widen setActiveTab prop to (number | string). - CorpusResearchReportCards: mount-guard the refetch effect to avoid a double fetch on initial mount; add explicit 'Could not load reports' error render when error && no reports loaded. - navigationUtils: drop production console.warn in getResearchReportUrl ('#' sentinel already signals the caller). - useNotificationWebSocket: TODO(v2) on RESEARCH_REPORT_PROGRESS union member. * Fix research report card tests + harden fetch/error states - CorpusResearchReportCardsTestWrapper: set addTypename:false on the custom InMemoryCache so it agrees with MockedProvider; a typename-adding cache injected __typename into the query the mock link matched against, draining the mock bucket and resolving the final fire to an error. - CorpusResearchReportCards: switch fetchPolicy to cache-and-network (nextFetchPolicy cache-first) so re-renders serve from cache instead of a network-only refetch storm; only surface the error state once the request has settled (error && !loading) so a transient retry error doesn't flash over the spinner. - ResearchReportDetail.ct: add research--report-detail--citations docScreenshot after the citations-tab assertions. * Address review: research status/util hardening + StartResearchModal test - Remove dead RESEARCH_REPORT_PROGRESS member from the notification type union (no v1 emitter/handler); add it when the handler is wired. - getResearchStatus dev-warns on an unrecognized JobStatus instead of silently rendering 'Queued' (null/undefined still treated as the not-set-yet case). - Align the chat status tool's duration string with the frontend formatResearchDuration helper (sub-minute reads '42s', not '0m 42s'); add a regression test. - Cap StartResearchModal's optional title input at MAX_RESEARCH_TITLE_CHARS (255, mirroring the ResearchReport.title model column) + new smoke CT. - Key citation rows off the unique footnote, not the array index. - Add anonymous-user rejection test pinning @login_required on researchReportBySlug. - Clarify the cache-and-network/cache-first nextFetchPolicy comment. * Address review: atomic research finalize + clear report state on route change - finalize() now wraps the terminal content/status save and the source_annotations/source_documents M2M set() in a single transaction.atomic() block, so an interrupted Celery task can no longer leave a COMPLETED report whose citations have no backing provenance rows. - CentralRouteManager clears openedResearchReport in Phase 1 whenever the resolved route is no longer a research route (single source of truth), closing the stale-state gap on /research/:slug -> corpus/document routes and browser back/forward. Regression-guarded in centralRouteDiscipline.test.ts. * Apply black formatting to research_reports.py Collapse a source_documents.set(Document.objects.filter(...)) call onto one line — black flagged it (the only pre-commit/linter violation on HEAD; isort/flake8/pyupgrade all clean). Formatting only, no behavior change. * Address PR #1836 review: permission constant, terminal-status polling guard, CHANGELOG indent - Extract the research-report update permission string into a RESEARCH_REPORT_UPDATE_PERMISSION constant so a typo can't silently disable Cancel for all users (review #2). - isTerminalResearchStatus now treats an unrecognized status as terminal so the detail view never polls indefinitely on a future backend state the frontend doesn't recognize; null/Queued/Running stay non-terminal (review #3). Added a unit-test case. - Fix a lost 2-space continuation indent in the CHANGELOG SmartLabel entry (review #5). * fix(research): address review on deep-research frontend (#1814) - graphql-api: add the backend's transient JobStatus.CREATED to the frontend enum; it was missing, so isTerminalResearchStatus(CREATED) wrongly returned true and stopped status polling before the job left the pre-queue state - researchUtils: treat CREATED as non-terminal and display it as 'Queued' - ResearchReportDetail: rename isRunning -> isActive (it covers created/queued/ running), key warning chips by their text instead of array index, log the swallowed error in the cancel catch block - StartResearchModal: log the swallowed error in the submit catch block - tests: cover CREATED in getResearchStatus and isTerminalResearchStatus --------- Co-authored-by: Claude <noreply@anthropic.com>
Summary
Adds a "deep research" agent: while chatting with a corpus, a user can say "please research X, Y, and Z" and a long-lived (5–30 min) autonomous job kicks off in the background. The job runs a PydanticAI corpus agent with a strictly read-only retrieval surface plus two job-bound scratchpad tools, produces a markdown report with footnote-style citations, and notifies the user when complete.
The user comes back later, opens the notification (or the chat — a system message lands there too), and reads the report.
Architecture
New app
opencontractserver/research/:ResearchReportmodel — sibling ofAnalysis/Extract. Tracks corpus FK, prompt, status (JobStatus+ newCANCELLED), start/complete/last-progress timestamps,cancel_requestedflag, step budget, renderedcontentmarkdown, structuredfindings/citations/tool_call_log/model_usage/warningsJSON sidecars, M2Ms to cited annotations & touched documents, and FKs back to the originating conversation/message.ResearchReportService— canonical entry point per CLAUDE.md rule 7. Lifecycle transitions, scratchpad writes, terminalfinalize(runs the citation post-processor that converts<cite ids="…">claim</cite>placeholders into[^n]markers + a## Sourcesblock), concurrency soft-guard (refuses a second QUEUED/RUNNING report on the same corpus within an hour).Closed-citation-graph invariant — the heart of the no-hallucinated-citations guarantee.
record_findingvalidates everysupporting_source_idagainstPydanticAIDependencies.retrieved_annotation_ids(the existing accumulator that retrieval tools populate). Annotation IDs not surfaced by a retrieval tool in this run are rejected with an error string the model self-corrects on.Celery loop (
opencontractserver/tasks/research_tasks.py) mirrorsrun_agent_corpus_action. Builds the agent viaagents.for_corpus(...), runs withUsageLimits(request_limit=max_steps, request_tokens_limit=…), has a salvage path that composes findings into markdown if the budget exhausts without an explicitfinalize_report. Notifications + an inserted systemChatMessageland on every terminal state.Chat kickoff —
start_deep_researchtool auto-attached to the corpus agent for authenticated users.corpus_id/user_id/conversation_idare auto-injected fromPydanticAIDependencies(not LLM-controlled), closing the prompt-injection vector. The tool callsResearchReportService.start, enqueues the Celery task on commit, and returns a short confirmation so the chat turn ends gracefully.Plumbing changes (small, additive — both already worked for the document agent path):
restrict_tool_namesplumbed throughUnifiedAgentFactory.create_corpus_agent+PydanticAICorpusAgent.create.PydanticAIDependencies.conversation_id(new optional field) +build_inject_params_for_contextnow threadsconversation_idfor tools that declare it.GraphQL:
ResearchReportType,researchReport(id)/researchReports(corpusId?, status?)queries,startResearchReport(corpusId, prompt, title?, maxSteps?)/cancelResearchReport(id)mutations. All read paths go throughBaseService.get_or_none/BaseService.filter_visible(clean against theopencontracts.E001invariant).v1 scope decisions (confirmed before implementation)
is_publicsemantics. Sidesteps the IDOR-on-shared-report problem entirely.cancel_requestedflag polled between tool calls; partial findings are preserved.Test plan
docker compose -f test.yml run django pytest opencontractserver/tests/research/ -n 4 --dist loadscope(model + service + kickoff-tool tests)docker compose -f test.yml run django pytest opencontractserver/tests/architecture/ -n 4 --dist loadscope(confirm the service-layer invariant test still passes — none of the new graphql code uses forbidden Tier-0 identifiers)docker compose -f test.yml run django python manage.py migrate --plan(verify the two new migrations apply cleanly)start_deep_researchand returns a confirmation stringResearchReportrow transition QUEUED → RUNNING → COMPLETEDreport.contenthas footnote markers + a## Sourcesblock, and thatreport.citationsmatchesRESEARCH_REPORT_COMPLETEnotification fires and an inserted systemChatMessageshows up in the originating conversationcancelResearchReportmid-loop — confirm CANCELLED status + preserved partial findingsstartResearchReporttwice in a row on the same corpus — second call should return a friendly "already in progress" errorOut of scope for v1 (tracked for v2)
Sharing /
is_publicon reports; viewer-aware citation redaction for shared reports; document-subset scoping; resume-from-partial-findings; watchdog cleanup task for crashed workers (thelast_progress_atcolumn is in place for when it's added); per-user notification preferences;AgentConfigurationintegration for saved research presets; frontendResearchReportView.See the CHANGELOG entry for the full rationale and field-by-field documentation.
🤖 Generated with Claude Code
Generated by Claude Code