Skip to content

fix(aiq-research): handle interrupted jobs and JSON escalation contract - #360

Merged
rapids-bot[bot] merged 4 commits into
NVIDIA-AI-Blueprints:release/2.2from
tanleach:fix/research-helper-terminal-escalation
Jul 17, 2026
Merged

fix(aiq-research): handle interrupted jobs and JSON escalation contract#360
rapids-bot[bot] merged 4 commits into
NVIDIA-AI-Blueprints:release/2.2from
tanleach:fix/research-helper-terminal-escalation

Conversation

@tanleach

@tanleach tanleach commented Jul 16, 2026

Copy link
Copy Markdown
Collaborator

Overview

The aiq-research skill helper (skills/aiq-research/scripts/aiq.py) had two contract mismatches with the 26.07 backend. Both were verified to still reproduce at release/2.2 HEAD, and the fix was checked against the backend source that defines each contract.

1. interrupted was not a terminal job state. _DONE_JOB_STATES and _FAILED_JOB_STATES omitted interrupted, but the backend sets a cancelled job to that state (JobStatus.INTERRUPTED = "interrupted" in nat async_jobs/job_store.py; the cancel route in frontends/aiq_api/.../routes/jobs.py returns {"status": "interrupted"}). Because the poll loop only exits on a member of _DONE_JOB_STATES, a cancelled job kept polling every 15s until the 3600s timeout. Adding interrupted to both sets makes polling stop immediately and report the job as failed rather than completed successfully.

2. Deep-research escalation was detected only in the legacy text format. _command_chat matched only Job ID: <uuid>, so the backend's structured escalation payload was printed raw and never surfaced as deep_research_running, leaving the documented SKILL.md Step 2/3 flow stuck. The backend emits the escalation as a compact JSON object placed in the assistant message content (_job_escalation_message in src/aiq_agent/agents/chat_researcher/agent.py):

{"type": "job_escalation", "kind": "deep_research", "job_id": "<uuid>"}

A new _detect_deep_research_escalation helper recognizes this object both as the top-level /chat result and as JSON embedded in the message content, validates the job_id as a UUID, and emits the documented {"status": "deep_research_running", "job_id": "<uuid>"}. It is tried before the legacy Job ID: regex, which is retained for backward compatibility. Non-deep_research kinds (e.g. report_edit), malformed JSON, non-escalation JSON, and missing/invalid job IDs are deliberately ignored so they cannot produce a false deep_research_running.

No SKILL.md prose change is required: the emitted shape is unchanged, so the existing Steps 2–4 flow now advances as documented.

DCO sign-off for the squash commit

Signed-off-by: Tanner Leach tleach@nvidia.com

Validation

Run from the repo root:

uv run pytest tests/scripts/test_aiq_research_helper.py -q      # 12 passed
uv run ruff check skills/aiq-research/scripts/aiq.py tests/scripts/test_aiq_research_helper.py   # All checks passed
uv run pre-commit run --files skills/aiq-research/scripts/aiq.py tests/scripts/test_aiq_research_helper.py   # ruff, ruff-format, detect-secrets, EOF/whitespace all pass

Contract verification against release/2.2 source (no live 26.07 backend was required, and the fix was not validated against assumptions alone):

  • SK-1: JobStatus.INTERRUPTED = "interrupted" (nat async_jobs/job_store.py); cancel route returns {"status": "interrupted"} (frontends/aiq_api/src/aiq_api/routes/jobs.py).
  • SK-2: _job_escalation_message(kind, job_id) returns {"type":"job_escalation","kind":..., "job_id":...} and is delivered as AIMessage(content=...) (src/aiq_agent/agents/chat_researcher/agent.py), matching the "escalation embedded in message content" path the helper handles.

New test matrix (tests/scripts/test_aiq_research_helper.py, loaded via importlib to match the standalone-script tests already in tests/scripts/):

  • interrupted membership in the terminal/failed state sets;

  • poll_until_complete returns on the first interrupted status (one status call, zero sleeps);

  • research_poll exits failure on interrupted without another poll cycle and without fetching a report;

  • JSON escalation detected as top-level result and as embedded content;

  • _command_chat emits deep_research_running;

  • legacy Job ID: <uuid> still detected;

  • guards: malformed JSON, non-escalation JSON, unsupported kind, missing/invalid/non-string job_id, and shallow-answer fall-through.

  • I ran the relevant local checks or explained why they are not applicable.

  • I added or updated tests for behavior changes.

  • I updated documentation for user-facing or contributor-facing changes.

  • I confirmed this PR does not include secrets, credentials, or internal-only data.

  • I certify this contribution under the Developer Certificate of Origin (DCO) and signed my commits with git commit -s or an equivalent sign-off.

  • I replaced the DCO sign-off placeholder with my GitHub commit identity and kept the required angle brackets around the email address.

Where should reviewers start?

  • skills/aiq-research/scripts/aiq.py: the state-set change (_DONE_JOB_STATES / _FAILED_JOB_STATES) and the new _escalation_job_id / _detect_deep_research_escalation helpers plus the reworked _command_chat ordering (JSON escalation first, legacy regex retained).
  • tests/scripts/test_aiq_research_helper.py: the guard cases confirm no false deep_research_running on malformed / non-escalation / unsupported-kind / invalid-job_id payloads.

Related Issues

Summary by CodeRabbit

  • Bug Fixes

    • Deep-research escalations are now detected reliably from chat responses.
    • Valid job IDs are reported with a deep_research_running status.
    • Interrupted research jobs are treated as terminal failures and stop polling promptly.
    • Invalid or malformed job IDs now safely fall back to displaying the original response.
  • Tests

    • Added coverage for escalation formats, interrupted jobs, legacy job-ID parsing, and malformed responses.

The aiq-research helper had two contract mismatches with the 26.07 backend.

- Terminal states: `interrupted` was absent from `_DONE_JOB_STATES` and
  `_FAILED_JOB_STATES`, so a cancelled job kept polling every 15s until the
  3600s timeout. Add `interrupted` to both sets so polling stops immediately
  and the job is reported as failed rather than completed.

- Escalation detection: `_command_chat` recognized only the legacy
  `Job ID: <uuid>` text, so the backend's JSON
  `{"type":"job_escalation","kind":"deep_research","job_id":"..."}` response
  was printed raw and never surfaced as `deep_research_running`. Add
  `_detect_deep_research_escalation` (matching the escalation object both as
  the top-level /chat result and as JSON embedded in message content, with a
  UUID-validated job_id) and try it before the legacy regex, which is
  retained for backward compatibility.

Add tests/scripts/test_aiq_research_helper.py covering interrupted terminal
handling, the JSON escalation response, the retained legacy format, and the
malformed / non-escalation / unsupported-kind / invalid-job_id guards.

Signed-off-by: Tanner Leach <tleach@nvidia.com>
@copy-pr-bot

copy-pr-bot Bot commented Jul 16, 2026

Copy link
Copy Markdown

Auto-sync is disabled for draft pull requests in this repository. Workflows must be run manually.

Contributors can view more details about this message here.

@coderabbitai

coderabbitai Bot commented Jul 16, 2026

Copy link
Copy Markdown

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Enterprise

Run ID: c651c26c-cf30-4153-8b49-b89ca08759c7

📥 Commits

Reviewing files that changed from the base of the PR and between c9c1d2f and 033ee25.

📒 Files selected for processing (2)
  • skills/aiq-research/scripts/aiq.py
  • tests/scripts/test_aiq_research_helper.py
📜 Recent review details
🧰 Additional context used
📓 Path-based instructions (3)
**/*.py

📄 CodeRabbit inference engine (CONTRIBUTING.md)

Run ruff check and ruff format validation for Python code changes

**/*.py: Python code must be linted and formatted with Ruff using line length 120, target Python 3.11, rule sets E,F,W,I,PL,UP, and isort force-single-line configuration
Never commit secrets, tokens, or environment-specific hostnames; use environment variables and SecretStr instead, resolving API keys at runtime
Never print or log secret values, including in tool output or error messages
Missing-secret paths must degrade gracefully (stub/skip), not crash or leak
Do not hand-reformat unrelated code when making changes; match the existing import and formatting style

Files:

  • skills/aiq-research/scripts/aiq.py
  • tests/scripts/test_aiq_research_helper.py
{skills/**,.agents/skills/**,.claude/skills/**,.github/skill-eval/**}

⚙️ CodeRabbit configuration file

{skills/**,.agents/skills/**,.claude/skills/**,.github/skill-eval/**}: Review Agent Skill and skill-eval changes for valid skill metadata, deterministic eval specs, safe handling of
credentials, and clear generated-output boundaries. Do not flag SKILL.md files for missing SPDX headers when the
entrypoint intentionally starts with YAML frontmatter.

Files:

  • skills/aiq-research/scripts/aiq.py
**/*test*.py

📄 CodeRabbit inference engine (CONTRIBUTING.md)

Run pytest for all behavior changes in Python code

Files:

  • tests/scripts/test_aiq_research_helper.py
🪛 ast-grep (0.44.1)
skills/aiq-research/scripts/aiq.py

[info] 505-505: use jsonify instead of json.dumps for JSON output
Context: json.dumps({"status": _STATUS_DEEP_RESEARCH_RUNNING, "job_id": legacy_job_id})
Note: [CWE-116] Improper Encoding or Escaping of Output.

(use-jsonify)


[info] 507-507: use jsonify instead of json.dumps for JSON output
Context: json.dumps(result, indent=JSON_INDENT_SPACES)
Note: [CWE-116] Improper Encoding or Escaping of Output.

(use-jsonify)

🔇 Additional comments (2)
skills/aiq-research/scripts/aiq.py (1)

84-93: LGTM!

Also applies to: 457-507

tests/scripts/test_aiq_research_helper.py (1)

1-43: LGTM!

Also applies to: 45-83, 85-118, 120-130, 133-157, 159-166


Walkthrough

The AIQ research CLI now treats interrupted jobs as terminal failures and detects validated deep-research escalation payloads from structured or embedded chat responses, while preserving validated legacy job-ID parsing and raw-response fallback behavior.

Changes

AIQ research CLI behavior

Layer / File(s) Summary
Terminal interrupted job handling
skills/aiq-research/scripts/aiq.py, tests/scripts/test_aiq_research_helper.py
Polling recognizes interrupted and failure as terminal states, classifies interrupted as failed, and exits without fetching a report.
Deep-research escalation detection
skills/aiq-research/scripts/aiq.py, tests/scripts/test_aiq_research_helper.py
Chat handling validates escalation payloads and job IDs from structured results or embedded JSON, preserves valid legacy extraction, and prints raw results for invalid or unrelated responses.

Estimated code review effort: 3 (Moderate) | ~20 minutes

Sequence Diagram(s)

sequenceDiagram
  participant _command_chat
  participant chat_request
  participant _detect_deep_research_escalation
  participant stdout
  _command_chat->>chat_request: submit chat request
  chat_request-->>_command_chat: return chat result
  _command_chat->>_detect_deep_research_escalation: inspect result and embedded content
  _detect_deep_research_escalation-->>_command_chat: return validated job_id or None
  _command_chat->>stdout: print deep_research_running status or raw result
Loading
🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Title check ✅ Passed The title uses a valid Conventional Commits fix(scope) form and accurately summarizes the interrupted-job and escalation-contract changes.
Description check ✅ Passed The description matches the template with Overview, DCO sign-off, Validation, reviewer guidance, and issue section, and the content is sufficiently complete.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

Comment @coderabbitai help to get the list of available commands.

@tanleach
tanleach marked this pull request as ready for review July 17, 2026 19:56
@tanleach
tanleach requested a review from a team July 17, 2026 19:56

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@skills/aiq-research/scripts/aiq.py`:
- Around line 499-501: Validate the legacy job ID captured by _CHAT_JOB_ID_RE
through _validate_job_id before reporting deep-research status; if validation
fails, fall through to the raw result output. Add a malformed 36-character
legacy-match test in tests/scripts/test_aiq_research_helper.py covering this
fallback and asserting raw output instead of deep_research_running.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Enterprise

Run ID: 195e8813-b1cf-47b0-b20b-aea61415e8ea

📥 Commits

Reviewing files that changed from the base of the PR and between d20dcf3 and c9c1d2f.

📒 Files selected for processing (2)
  • skills/aiq-research/scripts/aiq.py
  • tests/scripts/test_aiq_research_helper.py
📜 Review details
🧰 Additional context used
📓 Path-based instructions (3)
**/*.py

📄 CodeRabbit inference engine (CONTRIBUTING.md)

Run ruff check and ruff format validation for Python code changes

**/*.py: Python code must be linted and formatted with Ruff using line length 120, target Python 3.11, rule sets E,F,W,I,PL,UP, and isort force-single-line configuration
Never commit secrets, tokens, or environment-specific hostnames; use environment variables and SecretStr instead, resolving API keys at runtime
Never print or log secret values, including in tool output or error messages
Missing-secret paths must degrade gracefully (stub/skip), not crash or leak
Do not hand-reformat unrelated code when making changes; match the existing import and formatting style

Files:

  • skills/aiq-research/scripts/aiq.py
  • tests/scripts/test_aiq_research_helper.py
{skills/**,.agents/skills/**,.claude/skills/**,.github/skill-eval/**}

⚙️ CodeRabbit configuration file

{skills/**,.agents/skills/**,.claude/skills/**,.github/skill-eval/**}: Review Agent Skill and skill-eval changes for valid skill metadata, deterministic eval specs, safe handling of
credentials, and clear generated-output boundaries. Do not flag SKILL.md files for missing SPDX headers when the
entrypoint intentionally starts with YAML frontmatter.

Files:

  • skills/aiq-research/scripts/aiq.py
**/*test*.py

📄 CodeRabbit inference engine (CONTRIBUTING.md)

Run pytest for all behavior changes in Python code

Files:

  • tests/scripts/test_aiq_research_helper.py
🪛 ast-grep (0.44.1)
skills/aiq-research/scripts/aiq.py

[warning] 87-87: Regex pattern passed to re is built from a non-literal (variable, call, concatenation, or f-string) value. If that value is attacker-controlled it can introduce a malicious pattern with catastrophic backtracking (ReDoS). Use a hardcoded literal pattern, or validate/escape untrusted input with re.escape() and bound the regex complexity before compiling.
Context: re.compile(rf"Job ID:\s*([0-9a-f-]{{{JOB_ID_HEX_DASH_LENGTH}}})", re.IGNORECASE)
Note: [CWE-1333] Inefficient Regular Expression Complexity.

(redos-non-literal-regex-python)


[info] 496-496: use jsonify instead of json.dumps for JSON output
Context: json.dumps({"status": _STATUS_DEEP_RESEARCH_RUNNING, "job_id": job_id})
Note: [CWE-116] Improper Encoding or Escaping of Output.

(use-jsonify)


[info] 500-500: use jsonify instead of json.dumps for JSON output
Context: json.dumps({"status": _STATUS_DEEP_RESEARCH_RUNNING, "job_id": match.group(CAPTURE_GROUP_JOB_ID)})
Note: [CWE-116] Improper Encoding or Escaping of Output.

(use-jsonify)

tests/scripts/test_aiq_research_helper.py

[info] 94-94: use jsonify instead of json.dumps for JSON output
Context: json.dumps(payload)
Note: [CWE-116] Improper Encoding or Escaping of Output.

(use-jsonify)

🔇 Additional comments (4)
skills/aiq-research/scripts/aiq.py (2)

84-93: LGTM!


457-488: LGTM!

tests/scripts/test_aiq_research_helper.py (2)

1-43: LGTM!

Also applies to: 48-82, 88-106


123-153: LGTM!

Comment thread skills/aiq-research/scripts/aiq.py Outdated
The legacy `Job ID: <uuid>` fallback in `_command_chat` reported any
36-character `[0-9a-f-]` capture as an active deep-research job, so a
malformed match could produce a false `deep_research_running`. Route the
captured id through `_validate_job_id` and fall through to raw result output
when it is not a valid UUID, matching the JSON escalation path.

Add a malformed 36-character legacy-match test asserting raw output rather
than `deep_research_running`.

Signed-off-by: Tanner Leach <tleach@nvidia.com>

@AjayThorve AjayThorve left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

good catch

@tanleach

Copy link
Copy Markdown
Collaborator Author

/merge

@rapids-bot
rapids-bot Bot merged commit b90d8a1 into NVIDIA-AI-Blueprints:release/2.2 Jul 17, 2026
11 checks passed
@AjayThorve AjayThorve added this to the v2.2 milestone Jul 17, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants