fix: delegate analysis planning from main agent

This commit is contained in:
wtx
2026-08-08 10:40:01 +08:00
parent bfbddb544c
commit 5f240d1eec
3 changed files with 324 additions and 40 deletions
@@ -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.
+19 -40
View File
@@ -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"
@@ -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"