diff --git a/docs/superpowers/plans/2026-08-07-main-agent-analysis-handoff-boundary.md b/docs/superpowers/plans/2026-08-07-main-agent-analysis-handoff-boundary.md new file mode 100644 index 000000000..d7c350c42 --- /dev/null +++ b/docs/superpowers/plans/2026-08-07-main-agent-analysis-handoff-boundary.md @@ -0,0 +1,204 @@ +# Main Agent Analysis Handoff Boundary Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Ensure every `new_analysis` request is passed to the existing IntentAgent without Main Agent workflow, mode, or stage selection. + +**Architecture:** Keep the existing RouterRuntime and proposal confirmation flow. Add one deterministic normalization boundary for `RouteDecision(goal="new_analysis")`, apply it after all route/confirmation branches, and remove the RNA-specific workflow override. The downstream IntentAgent, planner, frontend, contracts, and execution stages remain untouched. + +**Tech Stack:** Python 3.11, Pydantic v2, pytest/pytest-asyncio, FastAPI backend API. + +## Global Constraints + +- Modify production code only in `gfa_core/mas/agents/router_runtime.py`. +- Do not modify frontend, IntentAgent, PlanningAgentV2, stage dependencies, RNA-seq YAML, CLI, or backend stage execution. +- Preserve the generic proposal confirmation mechanism. +- Preserve the exact user analysis request passed into the proposal and downstream recognizer. +- Main Agent must not set `workflow_id`, `clarification_question`, or stages for a `new_analysis` decision. + +--- + +### Task 1: Lock the Main Agent handoff boundary with regression tests + +**Files:** +- Create: `tests/unit/test_router_analysis_handoff_boundary.py` +- Test: `tests/unit/test_router_analysis_handoff_boundary.py` + +**Interfaces:** +- Consumes: `RouterRuntime.run(...)`, `RouteDecision`, and `GoalLedger`. +- Produces: regression coverage proving that route-model workflow/mode clarification is discarded for new analyses. + +- [ ] **Step 1: Write a failing generic RNA-seq handoff test** + +Create a small fake tool registry whose `resolve_analysis_handoff_request()` returns its input unchanged. Monkeypatch `runtime._decide_route()` to return: + +```python +RouteDecision( + goal="new_analysis", + needs=["analysis_intent"], + subject="执行rnaseq", + clarification_question="请选择全基因组还是基因家族成员RNA-seq", + workflow_id="rnaseq_whole_genome", + analysis_request_kind="execution", +) +``` + +Call `RouterRuntime.run(query="执行rnaseq", ..., goal_ledger=GoalLedger(...))` and assert: + +```python +assert outcome.action == "clarify" +assert outcome.proposal is not None +assert outcome.proposal["request"] == "执行rnaseq" +assert "workflow_id" not in outcome.proposal +assert "全基因组还是" not in outcome.message +``` + +- [ ] **Step 2: Run the generic test and verify RED** + +Run: + +```bash +pytest -q tests/unit/test_router_analysis_handoff_boundary.py +``` + +Expected: FAIL because current `RouterRuntime.run()` returns the route model's mode clarification before creating a proposal. + +- [ ] **Step 3: Add parameterized explicit-mode and non-RNA analysis cases** + +Use the same runtime-level test for: + +```python +( + ("执行全基因组rnaseq", "rnaseq_whole_genome"), + ("对已确认家族成员执行rnaseq", "rnaseq_family_member"), + ("执行motif分析", "gene_family"), +) +``` + +For every case assert that the proposal request equals the original query and has no `workflow_id`. + +- [ ] **Step 4: Run all boundary tests and confirm the expected failures** + +Run: + +```bash +pytest -q tests/unit/test_router_analysis_handoff_boundary.py +``` + +Expected: the new-analysis cases fail only because the Main Agent still retains clarification/workflow selection. + +### Task 2: Implement the deterministic pass-through boundary + +**Files:** +- Modify: `gfa_core/mas/agents/router_runtime.py:218-230` +- Modify: `gfa_core/mas/agents/router_runtime.py:630-820` +- Test: `tests/unit/test_router_analysis_handoff_boundary.py` + +**Interfaces:** +- Consumes: `RouteDecision` produced by the route model or pending-proposal confirmation branch. +- Produces: `_analysis_handoff_decision(decision: RouteDecision) -> RouteDecision`. + +- [ ] **Step 1: Add the minimal normalization helper** + +Add one static method on `RouterRuntime`: + +```python +@staticmethod +def _analysis_handoff_decision(decision: RouteDecision) -> RouteDecision: + if decision.goal != "new_analysis": + return decision + return RouteDecision( + goal="new_analysis", + needs=["analysis_intent"], + subject=decision.subject, + referent=decision.referent, + interaction_kind=decision.interaction_kind, + analysis_request_kind=decision.analysis_request_kind, + confirms_pending_proposal=decision.confirms_pending_proposal, + ) +``` + +Apply it once after route decision, pending-proposal confirmation, and next-analysis normalization have converged, before any `clarification_question` early return or proposal construction. + +- [ ] **Step 2: Remove RNA-specific Main Agent workflow selection** + +Delete the `has_rnaseq_marker`, `explicit_family_member`, `explicit_whole_genome`, and replacement `RouteDecision` block in `RouterRuntime.run()`. Explicit mode words remain in `query` and therefore continue downstream unchanged. + +- [ ] **Step 3: Tighten the route prompt without adding new behavior** + +Replace the unspecified-RNA mode instruction with the general boundary: + +```text +For goal=new_analysis, do not ask for an analysis mode, select a workflow, or select stages. +Leave workflow_id and clarification_question null and request analysis_intent; the existing +IntentAgent and PlanningAgent own missing analysis choices and executable stage planning. +``` + +- [ ] **Step 4: Run boundary tests and verify GREEN** + +Run: + +```bash +pytest -q tests/unit/test_router_analysis_handoff_boundary.py +``` + +Expected: all tests pass. + +- [ ] **Step 5: Run focused existing conversation tests** + +Run: + +```bash +pytest -q tests/unit/test_agent_chat_actions.py tests/unit/test_autofill_entrypoints.py +``` + +Expected: all tests pass; generic proposal persistence, config dialog routing, and existing chat actions remain unchanged. + +### Task 3: Verify syntax, diff scope, and live backend behavior + +**Files:** +- Verify: `gfa_core/mas/agents/router_runtime.py` +- Verify: `tests/unit/test_router_analysis_handoff_boundary.py` +- Verify: `docs/superpowers/specs/2026-08-07-main-agent-analysis-handoff-boundary-design.md` +- Verify: `docs/superpowers/plans/2026-08-07-main-agent-analysis-handoff-boundary.md` + +**Interfaces:** +- Consumes: local implementation and the running `/api/agent/chat` and `/api/agent/plan` endpoints. +- Produces: fresh evidence for regression safety and end-to-end handoff behavior. + +- [ ] **Step 1: Compile the modified module** + +Run: + +```bash +/home/xia-lab/miniconda3/envs/test4py311/bin/python -m py_compile gfa_core/mas/agents/router_runtime.py +``` + +Expected: exit code 0 and no output. + +- [ ] **Step 2: Audit changed-file scope** + +Run: + +```bash +git diff --name-only +git diff --cached --name-only +``` + +Expected production change: only `gfa_core/mas/agents/router_runtime.py`. No frontend or downstream analysis file appears. + +- [ ] **Step 3: Exercise the real chat API with a fresh session** + +POST `执行rnaseq` to `/api/agent/chat` for the existing RNA-seq test job. Assert that the response does not ask whole-genome versus family-member mode and that its proposal request preserves `执行rnaseq`. + +- [ ] **Step 4: Confirm the proposal and inspect the recognizer trace** + +POST `是` in the same session. Assert that `recognize_analysis_intent` receives a query beginning with `执行rnaseq`, and that returned intent stages come from the downstream IntentAgent. + +- [ ] **Step 5: Verify plan fidelity** + +POST the returned intent to `/api/agent/plan`. Assert the plan stages equal the dependency expansion of the downstream intent rather than a Main Agent-authored list. + +- [ ] **Step 6: Report service state honestly** + +If the live service still runs old code, report that API verification is pending restart; do not claim the runtime behavior is fixed based only on unit tests. diff --git a/gfa_core/mas/agents/router_runtime.py b/gfa_core/mas/agents/router_runtime.py index 5ba61a18b..a2a5c948e 100644 --- a/gfa_core/mas/agents/router_runtime.py +++ b/gfa_core/mas/agents/router_runtime.py @@ -223,9 +223,10 @@ goal=new_analysis. Asking only for status or progress without requesting a new p goal=project_status. The Router must not choose the next stages itself. Do not select a workflow for ordinary chat, product help, external knowledge, existing results, job files, or utilities. -For an unspecified RNA-seq mode, leave workflow_id null and ask whether the request is -whole-genome or confirmed-family-member RNA-seq. Workflow contracts guide conversation -and handoff only; PlanningAgentV2 remains responsible for executable stage planning. +For goal=new_analysis, do not ask for an analysis mode, select a workflow, or select +stages. Leave workflow_id and clarification_question null and request analysis_intent; +the existing IntentAgent and PlanningAgent own missing analysis choices and executable +stage planning. For goal=new_analysis, classify analysis_request_kind as guidance when the user asks what should be done now, how to continue, or which analysis comes next. Use execution @@ -428,6 +429,20 @@ class RouterRuntime: "utility": "propose_utility", }[decision.goal] + @staticmethod + def _analysis_handoff_decision(decision: RouteDecision) -> RouteDecision: + if decision.goal != "new_analysis": + return decision + return RouteDecision( + goal="new_analysis", + needs=["analysis_intent"], + subject=decision.subject, + referent=decision.referent, + interaction_kind=decision.interaction_kind, + analysis_request_kind=decision.analysis_request_kind, + confirms_pending_proposal=decision.confirms_pending_proposal, + ) + @staticmethod def _pending_analysis_proposal( goal_ledger: GoalLedger | None, @@ -730,43 +745,7 @@ class RouterRuntime: message="The Router returned two invalid route decisions.", tool_trace=decision_trace, ) - query_lower = str(query or "").lower() - has_rnaseq_marker = any( - token in query_lower - for token in ("rna-seq", "rnaseq", "rna seq", "转录组") - ) - explicit_family_member = has_rnaseq_marker and any( - token in query_lower - for token in ( - "基因家族成员", - "家族成员", - "family member", - "family-member", - ) - ) - explicit_whole_genome = has_rnaseq_marker and any( - token in query_lower - for token in ("全基因组", "whole genome", "whole-genome") - ) - if ( - confirmed_proposal is None - and (explicit_family_member or explicit_whole_genome) - and ( - decision.goal != "new_analysis" - or decision.clarification_question is not None - ) - ): - decision = RouteDecision( - goal="new_analysis", - needs=["analysis_intent"], - subject=query, - interaction_kind=decision.interaction_kind, - workflow_id=( - "rnaseq_family_member" - if explicit_family_member - else "rnaseq_whole_genome" - ), - ) + decision = self._analysis_handoff_decision(decision) authorized_input_paths = _extract_authorized_input_paths(query) if ( decision.goal == "new_analysis" diff --git a/tests/unit/test_router_analysis_handoff_boundary.py b/tests/unit/test_router_analysis_handoff_boundary.py new file mode 100644 index 000000000..e47e83eda --- /dev/null +++ b/tests/unit/test_router_analysis_handoff_boundary.py @@ -0,0 +1,101 @@ +from __future__ import annotations + +import asyncio +from typing import Any + +import pytest + +from gfa_core.mas.agents.router_runtime import ( + RouteDecision, + RouterRuntime, +) +from gfa_core.mas.workflow_models import GoalLedger + + +class _PassThroughTools: + @staticmethod + def resolve_analysis_handoff_request( + query: str, + *, + workflow_id: str | None = None, + guidance: bool = False, + ) -> str: + return query + + +@pytest.mark.parametrize( + ("query", "workflow_id", "clarification_question"), + ( + ( + "执行rnaseq", + "rnaseq_whole_genome", + "请选择全基因组还是基因家族成员RNA-seq", + ), + ("执行全基因组rnaseq", "rnaseq_whole_genome", None), + ("对已确认家族成员执行rnaseq", "rnaseq_family_member", None), + ("执行motif分析", "gene_family", None), + ), +) +def test_new_analysis_proposal_preserves_original_request_without_main_agent_planning( + monkeypatch: pytest.MonkeyPatch, + query: str, + workflow_id: str, + clarification_question: str | None, +) -> None: + runtime = RouterRuntime(object(), _PassThroughTools(), max_iterations=5) # type: ignore[arg-type] + + async def decide_route(**_: Any) -> tuple[RouteDecision, int, list[dict[str, Any]]]: + return ( + RouteDecision( + goal="new_analysis", + needs=["analysis_intent"], + subject=query, + clarification_question=clarification_question, + workflow_id=workflow_id, # type: ignore[arg-type] + analysis_request_kind="execution", + ), + 1, + [], + ) + + monkeypatch.setattr(runtime, "_decide_route", decide_route) + + outcome = asyncio.run( + runtime.run( + query=query, + history=[], + session_id="analysis-handoff-session", + job_id="job-analysis-handoff", + goal_ledger=GoalLedger( + conversation_id="analysis-handoff-session", + revision=0, + user_terms={"active_job_id": "job-analysis-handoff"}, + ), + ) + ) + + assert outcome.action == "clarify" + assert outcome.proposal is not None + assert outcome.proposal["request"] == query + assert "workflow_id" not in outcome.proposal + assert "全基因组还是" not in outcome.message + + +def test_new_analysis_decision_discards_main_agent_workflow_and_clarification() -> None: + decision = RouteDecision( + goal="new_analysis", + needs=["analysis_intent"], + subject="执行rnaseq", + clarification_question="请选择RNA-seq模式", + workflow_id="rnaseq_whole_genome", + analysis_request_kind="execution", + ) + + normalized = RouterRuntime._analysis_handoff_decision(decision) + + assert normalized.goal == "new_analysis" + assert normalized.needs == ["analysis_intent"] + assert normalized.subject == decision.subject + assert normalized.workflow_id is None + assert normalized.clarification_question is None + assert normalized.analysis_request_kind == "execution"