Skip to content

Deep-research agent: chat-triggered long-running corpus research with citations#1814

Merged
JSv4 merged 4 commits into
mainfrom
claude/sweet-lamport-vqbFb
May 28, 2026
Merged

Deep-research agent: chat-triggered long-running corpus research with citations#1814
JSv4 merged 4 commits into
mainfrom
claude/sweet-lamport-vqbFb

Conversation

@JSv4

@JSv4 JSv4 commented May 28, 2026

Copy link
Copy Markdown
Collaborator

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/:

  • ResearchReport model — sibling of Analysis/Extract. Tracks corpus FK, prompt, status (JobStatus + new CANCELLED), start/complete/last-progress timestamps, cancel_requested flag, step budget, rendered content markdown, structured findings/citations/tool_call_log/model_usage/warnings JSON 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, terminal finalize (runs the citation post-processor that converts <cite ids="…">claim</cite> placeholders into [^n] markers + a ## Sources block), 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_finding validates every supporting_source_id against PydanticAIDependencies.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) mirrors run_agent_corpus_action. Builds the agent via agents.for_corpus(...), runs with UsageLimits(request_limit=max_steps, request_tokens_limit=…), has a salvage path that composes findings into markdown if the budget exhausts without an explicit finalize_report. Notifications + an inserted system ChatMessage land on every terminal state.

Chat kickoffstart_deep_research tool auto-attached to the corpus agent for authenticated users. corpus_id / user_id / conversation_id are auto-injected from PydanticAIDependencies (not LLM-controlled), closing the prompt-injection vector. The tool calls ResearchReportService.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_names plumbed through UnifiedAgentFactory.create_corpus_agent + PydanticAICorpusAgent.create.
  • PydanticAIDependencies.conversation_id (new optional field) + build_inject_params_for_context now threads conversation_id for tools that declare it.

GraphQL: ResearchReportType, researchReport(id) / researchReports(corpusId?, status?) queries, startResearchReport(corpusId, prompt, title?, maxSteps?) / cancelResearchReport(id) mutations. All read paths go through BaseService.get_or_none / BaseService.filter_visible (clean against the opencontracts.E001 invariant).

v1 scope decisions (confirmed before implementation)

  • Creator-only visibility — no sharing, no is_public semantics. Sidesteps the IDOR-on-shared-report problem entirely.
  • Corpus-wide — no document-subset selector. The agent crawls everything in the named corpus.
  • Cooperative cancelcancel_requested flag 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)
  • Manual end-to-end against a real corpus:
    1. Open a corpus chat as an authenticated user
    2. Ask the assistant: "Please do a deep research investigation on the indemnification clauses in this corpus." — confirm the LLM invokes start_deep_research and returns a confirmation string
    3. Watch the ResearchReport row transition QUEUED → RUNNING → COMPLETED
    4. Verify report.content has footnote markers + a ## Sources block, and that report.citations matches
    5. Verify a RESEARCH_REPORT_COMPLETE notification fires and an inserted system ChatMessage shows up in the originating conversation
    6. Try cancelResearchReport mid-loop — confirm CANCELLED status + preserved partial findings
    7. Try startResearchReport twice in a row on the same corpus — second call should return a friendly "already in progress" error

Out of scope for v1 (tracked for v2)

Sharing / is_public on reports; viewer-aware citation redaction for shared reports; document-subset scoping; resume-from-partial-findings; watchdog cleanup task for crashed workers (the last_progress_at column is in place for when it's added); per-user notification preferences; AgentConfiguration integration for saved research presets; frontend ResearchReportView.

See the CHANGELOG entry for the full rationale and field-by-field documentation.


🤖 Generated with Claude Code


Generated by Claude Code

…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.
Comment thread opencontractserver/research/constants.py Fixed
Comment thread opencontractserver/research/constants.py Fixed
Comment thread opencontractserver/research/constants.py Fixed
Comment thread opencontractserver/research/constants.py Fixed
Comment thread opencontractserver/research/constants.py Fixed
Comment thread opencontractserver/research/constants.py Fixed
Comment thread opencontractserver/research/constants.py Fixed
Comment thread opencontractserver/research/constants.py Fixed
Comment thread opencontractserver/research/constants.py Fixed
Comment thread config/graphql/research_mutations.py Fixed
@claude

claude Bot commented May 28, 2026

Copy link
Copy Markdown

Code Review

This 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. ask_document doesn't populate retrieved_annotation_ids — citation invariant is broken for sub-agent sources

DEEP_RESEARCH_READ_ONLY_TOOLS includes ask_document, and ask_document_tool returns DocAnswer.sources with real annotation IDs. However, it never appends those IDs to ctx.deps.retrieved_annotation_ids. This means any annotation the LLM sees via ask_document is silently rejected when it tries to cite it via record_finding (the closure at task line ~2038 validates against retrieved_annotation_ids). Either wire the sub-agent's captured annotation IDs back into the accumulator, or remove ask_document from DEEP_RESEARCH_READ_ONLY_TOOLS until it's wired correctly.

2. Missing migration for Analysis.status after CANCELLED added to JobStatus

Adding CANCELLED to opencontractserver/types/enums.py changes the choices list for Analysis.status in opencontractserver/analyzer/models.py (the choices are derived dynamically from JobStatus). Django will detect this as an AlterField and require a migration. No such migration is in this PR. CI will likely flag unapplied migrations.

3. SoftTimeLimitExceeded handled as FAILED instead of CANCELLED

The Celery soft_time_limit (30 min) raises celery.exceptions.SoftTimeLimitExceeded inside asyncio.run(...). It propagates to the generic except Exception as exc: handler, which calls mark_failed() and sends a RESEARCH_REPORT_FAILED notification — discarding partial findings with a misleading error. The correct path is to catch SoftTimeLimitExceeded explicitly, call mark_cancelled(), preserve partial findings, and send RESEARCH_REPORT_CANCELLED.

4. AnnotatePermissionsForReadMixin used without guardian permission model classes

ResearchReportType inherits AnnotatePermissionsForReadMixin, which calls getattr(self, "{model_name}userobjectpermission_set"). ResearchReport has no ResearchReportUserObjectPermission / ResearchReportGroupObjectPermission model classes and no corresponding migration (compare every other guarded model in the codebase). The mixin wraps the failure in a broad except Exception, so it silently returns myPermissions: [] for the creator — they see no permissions on their own reports. Either add the guardian permission model classes + migration, or remove AnnotatePermissionsForReadMixin and implement a simple creator-only resolve_my_permissions.

5. from_global_id() / int() not wrapped in try/except in GraphQL layer

research_queries.py line ~22: django_pk = int(from_global_id(kwargs["id"])[1]) — a malformed base64 input or non-numeric decoded value raises ValueError / UnicodeDecodeError, surfacing as a 500. The same pattern appears in research_mutations.py for both cancel and start mutations. Compare search_queries.py which properly wraps in try/except (ValueError, TypeError, UnicodeDecodeError).


Should-Fix (Non-Blocking but Important)

6. Race condition in concurrency guard

In ResearchReportService.start(), the .exists() check and .create() are not in the same transaction.atomic() + SELECT ... FOR UPDATE block. Two simultaneous requests from the same user on the same corpus can both pass the guard before either creates the row. Either add a unique DB constraint on (creator, corpus, status__in=[QUEUED, RUNNING]) or wrap both operations in a single atomic block with select_for_update().

7. No ceiling on user-supplied max_steps

ResearchReportService.start() accepts the user's max_steps without a maximum cap. A caller could pass max_steps=100000 via the GraphQL mutation and burn an enormous LLM budget. Add a hard ceiling (e.g., min(user_value, settings.DEEP_RESEARCH_MAX_STEPS_CEILING)).

8. Double-write in salvage path

In research_tasks.py, when the budget exhausts: ResearchReportService.finalize() is called (sets status = COMPLETED, saves), and then ResearchReportService.mark_completed() is called again immediately (sets status = COMPLETED again with a new completed_at, wasting a DB round-trip). Since finalize() already handles status, remove the subsequent mark_completed() call.

9. Missing @login_required on resolve_research_report

The list resolver resolve_research_reports has @login_required, but the single-object resolver resolve_research_report does not. While BaseService.get_or_none() with visible_to_user(None) will return None for anonymous users (no data leak), the inconsistency is worth closing. Add @login_required.

10. Unlimited prompt length

Neither the GraphQL mutation nor ResearchReportService.start() validates prompt length. A caller can submit an arbitrarily large prompt, causing excessive token use or DB bloat. Add a reasonable max (e.g., 10,000 chars).

11. Status filter not validated in resolve_research_reports

research_queries.py line ~43: qs = qs.filter(status=status) accepts an arbitrary string from the query variable. Filtering by a non-existent status silently returns an empty queryset with no error. Either validate against JobStatus values or use status__in.


Test Coverage Gaps

  • No tests for the run_deep_research Celery task itself (only the kickoff tool is tested)
  • No tests for the salvage path (budget exhausted before finalize_report)
  • No test for SoftTimeLimitExceeded handling
  • No GraphQL layer tests (startResearchReport, cancelResearchReport, researchReports, researchReport node)
  • No test for myPermissions returning correct values (would catch finding Bump responses from 0.21.0 to 0.22.0 #4)
  • No test confirming ask_document citation accumulation (would catch finding Bump postgres from 14.5 to 15.0 in /compose/production/postgres #1)

Minor / Cleanup

  • Dead setting: DEEP_RESEARCH_STUCK_THRESHOLD_SECONDS is defined in settings/base.py but never referenced anywhere. Remove it or add a # reserved for v2 watchdog comment.
  • Misleading restrict union: research_tasks.py adds SCRATCHPAD_TOOL_NAMES to restrict_tool_names, but the agent explicitly states caller-supplied closure tools bypass restrict_tool_names. Including them in restrict is harmless but misleading — add a comment or remove.
  • Magic number 60: models.py uses getattr(settings, "DEEP_RESEARCH_DEFAULT_MAX_STEPS", 60) — the fallback 60 should reference a named constant from constants.py per CLAUDE.md rule 4.
  • re imported inside function body: _render_citations imports re inside the function — move to module level.
  • CREATED status is unreachable: Reports are created at QUEUED, skipping CREATED. The dead enum choice adds noise; document this intentional divergence from the Analyzer pattern.

What's Working Well

The design of the closed-citation-graph invariant (enforced at both record_finding and finalize) is well-thought-out. corpus_id/user_id/conversation_id are all injected from PydanticAIDependencies rather than LLM-controlled — this correctly closes the prompt-injection vector. The service layer is clean and consistent with the project's architecture. Prompt content is fenced with fence_user_content(). The cooperative cancel design (polled cancel_requested flag between tool calls) is appropriate for this use case.

JSv4 added 2 commits May 27, 2026 21:17
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.
Comment on lines +91 to +92
"You are a deep-research analyst executing an autonomous, multi-step "
"investigation across a document corpus.",
Comment on lines +97 to +99
"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.",
Comment on lines +100 to +104
"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.",
Comment on lines +105 to +106
"4. `finalize_report` is the terminal action. Once you call it, the "
"run ends.",
Comment on lines +109 to +111
"- 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.",
Comment on lines +113 to +114
"- Do NOT speculate beyond what the corpus supports. If the corpus "
"does not contain the answer, say so explicitly in the report.",
Comment on lines +115 to +116
"- Use `record_finding` liberally — it is your scratchpad. Findings "
"are persisted between tool calls and survive a worker crash.",
Comment on lines +120 to +121
"- Prefer broad coverage early (vector + exact-text searches across "
"several queries), then drill into the most promising documents.",
Comment on lines +139 to +141
"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
@claude

claude Bot commented May 28, 2026

Copy link
Copy Markdown

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 Violation

PR 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:

🤖 Generated with Claude Code
_Generated by Claude Code (session_01Ns...)_

These should be removed before merging.


Bug: Broken Chat-Message Link

opencontractserver/tasks/research_tasks.py_insert_completion_chat_message:

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 _decode_global_pk

The helper is copy-pasted verbatim into both config/graphql/research_mutations.py and config/graphql/research_queries.py. Per CLAUDE.md rule 2, extract it to a shared location — the natural home is config/graphql/utils.py or the closest existing utility file in config/graphql/.


Permission Inconsistency: start_deep_research_tool

opencontractserver/llms/tools/research_tools.py declares requires_write_permission=True, but ResearchReportService.start() only checks PermissionTypes.READ. A user with READ-but-not-WRITE hits the approval gate, then is allowed through by the service anyway. If READ is sufficient to kick off research, change the CoreTool declaration accordingly; if WRITE is genuinely required, align the service check.


Data Inconsistency Window in finalize()

opencontractserver/research/services/research_reports.py:

report.save(update_fields=update_fields)   # status = COMPLETED here

if citations:
    report.source_annotations.set(...)    # outside the save — crash window
    report.source_documents.set(...)

A worker killed between the save() and the M2M writes leaves the report COMPLETED with empty provenance M2Ms. Wrap the whole block in with transaction.atomic(): to close this.


Missing Tests: Celery Task Paths

run_deep_research has no direct tests. Uncovered paths include:

  • SoftTimeLimitExceeded → CANCELLED + notification
  • Generic exception → FAILED + notification
  • Salvage path (_compose_salvage_body + second finalize when budget exhausts)
  • Notification + chat message insertion on every terminal state

These are exactly the paths that fail silently in production. A minimal task-layer test with mocked agents.for_corpus would catch regressions.


Dead Code: RESEARCH_REPORT_PROGRESS Notification Type

Defined in notifications/models.py and migrated in 0005_…, but ResearchReportService.mark_progress() never emits it. Either remove the type (and simplify the migration) or emit it with appropriate throttling. If it's reserved for a future polling endpoint, a short comment to that effect prevents the next reader from treating it as accidental dead code.


Minor: status Filter Should Be a GraphQL Enum

config/graphql/research_queries.py declares status=graphene.String(required=False), forcing clients to guess valid values. Using graphene.Enum.from_enum(JobStatus) surfaces valid values in schema introspection and eliminates the manual validation block below it. Other query files in this codebase use GraphQL enums for status fields.


Minor: deps_ref Relies on an Internal Attribute

deps_ref["deps"] = getattr(agent, "agent_deps", None)

When agent_deps is None (e.g. after a PydanticAI wrapper rename), record_finding silently rejects every source ID and the agent records no findings. It fails safely but invisibly. An explicit assertion or raised exception right after the getattr would surface the misconfiguration immediately.


Nit: _SOFT_TIME_LIMIT / _HARD_TIME_LIMIT Evaluated at Import Time

Celery task decoration happens at module import, so @override_settings(DEEP_RESEARCH_SOFT_TIME_LIMIT=…) in tests will not change the task's registered limits. Standard Celery behaviour, but worth a comment so future test authors don't chase a confusing no-op.


Positive Highlights

  • Closed-citation-graph invariant — the two-layer check (tool-time rejection in record_finding + finalize-time intersection) is exactly right.
  • SELECT FOR UPDATE concurrency guard in ResearchReportService.start() correctly closes the TOCTOU window on the (creator, corpus) pair.
  • Salvage path gracefully recovers partial findings when the budget exhausts without an explicit finalize_report call.
  • prompt_sanitization fencing in build_deep_research_system_prompt correctly treats corpus metadata and the user's task as untrusted content.
  • Creator-only visibility sidesteps the IDOR-on-shared-report problem cleanly; the manager shape is designed for easy widening in v2.
  • Service-layer test coverage (lifecycle transitions, concurrency guard, citation post-processing, deleted-annotation edge cases) is excellent.

Signed-off-by: JSIV <5049984+JSv4@users.noreply.github.com>
@JSv4 JSv4 merged commit 7501a95 into main May 28, 2026
12 checks passed
@JSv4 JSv4 deleted the claude/sweet-lamport-vqbFb branch May 28, 2026 05:10
@claude

claude Bot commented May 28, 2026

Copy link
Copy Markdown

Code Review — PR #1814: Deep-research agent

This 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

similarity_score documented but never populated (research/services/research_reports.py)
The citations JSONField help text and the PR description both reference similarity_score as a citation table field, but _render_citations never sets it. Every citation entry emitted will be missing this key. If the frontend or any consumer tries to read it, they'll get a KeyError / undefined. Either populate it (by fetching from the retrieval result metadata) or drop it from the documented schema.

Time-limit constants evaluated at import time (tasks/research_tasks.py:2114)

_SOFT_TIME_LIMIT, _HARD_TIME_LIMIT = _resolve_time_limits()

@shared_task(soft_time_limit=_SOFT_TIME_LIMIT, ...)
def run_deep_research(...):

Because @shared_task arguments are evaluated at decoration time (module import), override_settings(DEEP_RESEARCH_SOFT_TIME_LIMIT=...) in tests has no effect on the actual task limits. Any test that wants to verify timeout behaviour will silently run with production limits. Document this limitation prominently, or use a different approach (e.g. read settings inside the task body and use a shorter constant for decorator args).


DRY Violation

_decode_global_pk defined twice (config/graphql/research_mutations.py:100 and config/graphql/research_queries.py:215)
These are byte-for-byte identical. Per CLAUDE.md rule 2, extract it to a shared location (e.g. a config/graphql/utils.py or import it from whichever file defines it first). Several other query files already use their own copies — this would be a good time to consolidate all of them.


Architecture / Conventions

_load_user_and_corpus bypasses the service layer (llms/tools/research_tools.py:702-707)

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, corpus

CLAUDE.md rule 7 requires code with a user context to go through services/. The corpus_id here comes from PydanticAIDependencies (trusted), so there's no IDOR risk in practice — but the pattern still bypasses the invariant enforced by opencontracts.E001. The same applies to _load_conversation_context. Use CorpusService / ConversationService (or BaseService.get_or_none) here.

getattr(agent, "agent_deps", None) is fragile (tasks/research_tasks.py:2305)

deps_ref["deps"] = getattr(agent, "agent_deps", None)

agent_deps appears to be an internal attribute of PydanticAICorpusAgent. If the attribute is renamed or the implementation changes, deps_ref["deps"] silently becomes None, the retrieved set becomes empty, and record_finding rejects every annotation ID — completely breaking citation recording without any exception. A more robust solution would be for the agent factory to expose deps through a public method or return value.

Crashed-worker RUNNING jobs block the concurrency guard indefinitely
A worker crash mid-research leaves the report stuck in RUNNING with no cancel_requested. The concurrency guard (status__in=[QUEUED, RUNNING]) will block new research on that (user, corpus) until the guard window expires (1 hour default). The last_progress_at column is in place (good), but the watchdog is deferred to v2. Consider adding a note in the start() method that explains why the guard window is bounded (i.e. the guard is a soft block, not a hard serialiser).

requires_write_permission=True on start_deep_research_tool (llms/tools/research_tools.py:759)
The research task is strictly read-only on the corpus. Requiring corpus WRITE permission just to kick off a read-only investigation is more restrictive than necessary and could surprise users who have read-only corpus access. Consider whether corpus READ is the right gate here (matching what ResearchReportService.start() checks internally).


Performance

append_tool_call does a refresh_from_db on every call (research/services/research_reports.py:1735)
The comment says "Cheap; does not bump progress" but each call still does a SELECT + UPDATE round-trip. For a 60-step run that logs every tool call, that's 60 unnecessary refreshes. The refresh_from_db in append_finding is justified (it reads cancel_requested), but append_tool_call doesn't need to check that flag. Consider removing the refresh there, or at least not reading fields that aren't used.

resolve_full_source_annotation_list / resolve_full_source_document_list can cause N+1
Both resolvers call .all() on M2M relations with no prefetch hint. If researchReports is ever paginated in a list view, each node will fire two additional queries. Add a prefetch_related_fields or document that these fields should only be requested on single-object queries.


Test Coverage Gaps

  • No GraphQL-level tests for startResearchReport / cancelResearchReport / researchReports. The service and model are tested well, but the API surface (auth enforcement, relay ID decoding, error paths) is untested.
  • No test for the Celery task itself (run_deep_research), including the salvage path (_compose_salvage_body) and the SoftTimeLimitExceeded branch.
  • No test for build_deep_research_system_prompt with a None corpus description or an oversized prompt.
  • The kickoff tool tests correctly use TransactionTestCase — good.

Minor / Nits

  • is_public field exists but is silently ignored by the manager in v1. test_visible_to_user_anonymous_sees_nothing creates a report with is_public=True to confirm it's still hidden — good defensive test. But any future reader who sees is_public on the model and assumes it works like on other models will be confused. A # v1: is_public has no effect comment on the manager would help.
  • The _SOFT_TIME_LIMIT, _HARD_TIME_LIMIT module-level tuple unpack is unusual style for a settings-derived constant. A plain getattr(settings, ..., default) at the top of the task body would be more familiar and sidestep the import-time evaluation issue.
  • status field max_length=20 in the model but max_length=24 in the Analysis migration 0022_alter_analysis_status — not a bug (both fit CANCELLED), but the inconsistency is worth noting.

Summary

The 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 similarity_score omission, the import-time time-limit evaluation, the duplicated _decode_global_pk, and the service-layer bypass in _load_user_and_corpus.

JSv4 added a commit that referenced this pull request May 31, 2026
- 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
JSv4 added a commit that referenced this pull request May 31, 2026
…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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants