import asyncio import json from pydantic import BaseModel from gfa_core.mas.agent_state_repository import SQLiteAgentStateRepository from gfa_core.mas.agents.conversation_tools import ConversationToolRegistry from gfa_core.mas.agents.router_runtime import RouterRuntime from gfa_core.mas.llm import LLMResponse, LLMToolCall from gfa_core.mas.persistent_conversation import PersistentConversationStore from gfa_core.mas.workflow_models import CurrentRecommendation, GoalLedger class _NoArgs(BaseModel): pass class _IntentArgs(BaseModel): query: str class _SequenceLLM: def __init__(self, responses): self.responses = list(responses) async def acall(self, *_args, **_kwargs): return self.responses.pop(0) def _response(content="", *, tool_calls=None): return LLMResponse( content=content, model="test-model", provider="test-provider", usage={}, tool_calls=list(tool_calls or []), ) def _recommendation(job_id="job-current-recommendation"): return CurrentRecommendation( recommendation_id="rec-build-subfamily-evidence", job_id=job_id, workflow_id="gene_family", transition_id="build_subfamily_evidence", from_state_id="family_identified", to_state_id="subfamily_evidence_ready", target_stage="combo", authorized_stages=("domain", "motif", "phylogeny", "structure", "combo"), evidence_digest="a" * 64, family="SPL", scope_id="SPL.target-Ft.target_only", ) def test_identified_project_context_projects_one_typed_recommendation(): recommendation = RouterRuntime._current_workflow_recommendation( "gene_family", "job-project-recommendation", { "family": "SPL", "scopes": [{"scope_id": "SPL.target-Ft.target_only"}], "manifests": [{"stage": "identify", "status": "success"}], "stage_status": { "identify": {"stage": "identify", "status": "success"} }, }, ) assert isinstance(recommendation, CurrentRecommendation) assert recommendation.transition_id == "build_subfamily_evidence" assert recommendation.target_stage == "combo" assert recommendation.authorized_stages == ( "domain", "motif", "phylogeny", "structure", "combo", ) def test_next_analysis_question_stays_read_only_and_returns_typed_recommendation(): job_id = "job-next-analysis-question" context = { "job_id": job_id, "family": "SPL", "scopes": [{"scope_id": "SPL.target-Ft.target_only"}], "manifests": [{"stage": "identify", "status": "success"}], "stage_status": { "identify": {"stage": "identify", "status": "success"} }, } registry = ConversationToolRegistry() registry.register( name="inspect_project_context", description="Inspect current project context.", args_model=_NoArgs, handler=lambda _args: context, permission="read_only", ) llm = _SequenceLLM( [ _response( '{"goal":"project_status","needs":["project_context"],' '"subject":"当前工作流的下一业务动作",' '"referent":null,"clarification_question":null,' '"interaction_kind":"status","workflow_id":"gene_family",' '"analysis_request_kind":null,' '"uses_current_recommendation":false,' '"confirms_pending_proposal":false}' ), _response( tool_calls=[ LLMToolCall( id="inspect-next-analysis-1", name="inspect_project_context", arguments="{}", ) ] ), _response( '{"status":"ok","action":"show_result",' '"message":"下一步是构建亚家族证据。"}' ), ] ) outcome = asyncio.run( RouterRuntime(llm, registry, max_iterations=5).run( query="接下来的分析有哪些?", history=[], session_id="session-next-analysis-question", job_id=job_id, goal_ledger=GoalLedger( conversation_id="conversation-next-analysis-question", revision=0, user_terms={"active_job_id": job_id, "gene_family": "SPL"}, ), ) ) assert outcome.action == "show_result" assert outcome.proposal is None assert outcome.recommendation is not None assert outcome.recommendation.transition_id == "build_subfamily_evidence" def test_show_result_persists_and_cancel_clears_current_recommendation(tmp_path): repository = SQLiteAgentStateRepository(tmp_path / "agent-state.sqlite3") repository.initialize() store = PersistentConversationStore(repository) turn = store.begin_turn( session_id="session-current-recommendation", job_id="job-current-recommendation", query="接下来的分析有哪些?", client_history=[], ) recommendation = _recommendation().model_dump(mode="json") ledger = store.finish_turn( turn, assistant_message="下一步是构建亚家族证据。", interaction_kind="status", action="show_result", recommendation=recommendation, ) assert ( ledger.model_dump(mode="json")["user_terms"]["current_recommendation"] == recommendation ) cancel_turn = store.begin_turn( session_id="session-current-recommendation", job_id="job-current-recommendation", query="取消", client_history=[], ) ledger = store.finish_turn( cancel_turn, assistant_message="已取消。", interaction_kind="cancel", action="reply", ) assert "current_recommendation" not in ledger.user_terms def test_pending_proposal_preserves_complete_workflow_action_binding(tmp_path): repository = SQLiteAgentStateRepository(tmp_path / "agent-state.sqlite3") repository.initialize() store = PersistentConversationStore(repository) turn = store.begin_turn( session_id="session-action-binding", job_id="job-action-binding", query="执行当前推荐", client_history=[], ) proposal = { "proposal_id": "proposal-action-binding", "request": "执行 build_subfamily_evidence", "subject": "执行当前推荐", "workflow_id": "gene_family", "workflow_action_id": "build_subfamily_evidence", "authorized_stages": "domain,motif,phylogeny,structure,combo", "target_stage": "combo", } ledger = store.finish_turn( turn, assistant_message="请确认。", interaction_kind="supplement", action="clarify", proposal=proposal, ) assert ledger.user_terms["pending_proposal"] == { **proposal, "job_id": "job-action-binding", } def test_execute_current_recommendation_revalidates_and_limits_intent(): job_id = "job-execute-current-recommendation" context = { "job_id": job_id, "family": "SPL", "scopes": [{"scope_id": "SPL.target-Ft.target_only"}], "manifests": [{"stage": "identify", "status": "success"}], "stage_status": { "identify": {"stage": "identify", "status": "success"} }, } recommendation = RouterRuntime._current_workflow_recommendation( "gene_family", job_id, context ) registry = ConversationToolRegistry() registry.register( name="inspect_project_context", description="Inspect current project context.", args_model=_NoArgs, handler=lambda _args: context, permission="read_only", ) registry.register( name="recognize_analysis_intent", description="Recognize an analysis intent.", args_model=_IntentArgs, handler=lambda _args: { "gene_family": "SPL", "stages": ["identify"], "primary_stage": "identify", "params": {"targetSpecies": ["Ft"], "referenceSpecies": ["At"]}, }, permission="pure", ) ledger = GoalLedger( conversation_id="conversation-execute-current-recommendation", revision=0, user_terms={ "active_job_id": job_id, "current_recommendation": recommendation.model_dump(mode="json"), }, ) llm = _SequenceLLM( [ _response( '{"goal":"new_analysis","needs":["analysis_intent"],' '"subject":"执行当前推荐动作","referent":"current_recommendation",' '"clarification_question":null,"interaction_kind":"supplement",' '"analysis_request_kind":"execution",' '"uses_current_recommendation":true,' '"confirms_pending_proposal":false}' ), _response( tool_calls=[ LLMToolCall( id="recognize-current-recommendation-1", name="recognize_analysis_intent", arguments=json.dumps( {"query": "执行接下来的分析"}, ensure_ascii=False ), ) ] ), _response( '{"status":"ok","action":"propose_analysis",' '"message":"已交给现有分析系统。"}' ), ] ) outcome = asyncio.run( RouterRuntime(llm, registry, max_iterations=5).run( query="执行接下来的分析", history=[], session_id="session-execute-current-recommendation", job_id=job_id, goal_ledger=ledger, ) ) assert outcome.action == "propose_analysis" assert outcome.intent["stages"] == [ "domain", "motif", "phylogeny", "structure", "combo", ] assert outcome.intent["primary_stage"] == "combo" assert outcome.intent["workflow_action_id"] == "build_subfamily_evidence" def test_semantic_business_referent_hands_off_when_workflow_is_terminal(): captured_queries = [] registry = ConversationToolRegistry() registry.register( name="recognize_analysis_intent", description="Recognize an analysis intent.", args_model=_IntentArgs, handler=lambda args: captured_queries.append(args.query) or { "gene_family": "SPL", "stages": ["promoter", "synteny", "ppi"], "primary_stage": "promoter", }, permission="pure", ) llm = _SequenceLLM( [ _response( '{"goal":"new_analysis","needs":["analysis_intent"],' '"subject":"执行上一轮说明的后续业务",' '"referent":"常规基因家族分析",' '"clarification_question":null,"interaction_kind":"supplement",' '"workflow_id":"gene_family","analysis_request_kind":"execution",' '"uses_current_recommendation":true,' '"confirms_pending_proposal":false}' ), _response( tool_calls=[ LLMToolCall( id="recognize-semantic-business-referent-1", name="recognize_analysis_intent", arguments=json.dumps( {"query": "执行常规基因家族分析"}, ensure_ascii=False ), ) ] ), _response( '{"status":"ok","action":"propose_analysis",' '"message":"分析请求已交给现有意图系统。"}' ), ] ) outcome = asyncio.run( RouterRuntime(llm, registry, max_iterations=5).run( query="执行接下来的分析", history=[ { "role": "assistant", "content": ( "亚家族分类与确认已经完成," "接下来进入常规基因家族分析。" ), } ], session_id="session-semantic-business-referent", job_id="job-confirmed-family", goal_ledger=GoalLedger( conversation_id="conversation-semantic-business-referent", revision=0, user_terms={"active_job_id": "job-confirmed-family"}, ), ) ) assert outcome.action == "propose_analysis" assert captured_queries == ["执行常规基因家族分析"] assert outcome.intent["stages"] == ["promoter", "synteny", "ppi"] def test_unresolved_current_recommendation_without_binding_still_clarifies(): llm = _SequenceLLM( [ _response( '{"goal":"new_analysis","needs":["analysis_intent"],' '"subject":"执行当前推荐动作",' '"referent":"current_recommendation",' '"clarification_question":null,"interaction_kind":"supplement",' '"workflow_id":"gene_family","analysis_request_kind":"execution",' '"uses_current_recommendation":true,' '"confirms_pending_proposal":false}' ) ] ) outcome = asyncio.run( RouterRuntime(llm, ConversationToolRegistry(), max_iterations=5).run( query="执行接下来的分析", history=[], session_id="session-unresolved-recommendation", job_id="job-unresolved-recommendation", goal_ledger=GoalLedger( conversation_id="conversation-unresolved-recommendation", revision=0, user_terms={"active_job_id": "job-unresolved-recommendation"}, ), ) ) assert outcome.action == "clarify" assert "明确" in outcome.message def test_execute_current_recommendation_rejects_cross_job_binding(): recommendation = _recommendation(job_id="job-original") ledger = GoalLedger( conversation_id="conversation-cross-job-recommendation", revision=0, user_terms={ "current_recommendation": recommendation.model_dump(mode="json") }, ) llm = _SequenceLLM( [ _response( '{"goal":"new_analysis","needs":["analysis_intent"],' '"subject":"执行当前推荐动作","referent":"current_recommendation",' '"clarification_question":null,"interaction_kind":"supplement",' '"analysis_request_kind":"execution",' '"uses_current_recommendation":true,' '"confirms_pending_proposal":false}' ) ] ) outcome = asyncio.run( RouterRuntime(llm, ConversationToolRegistry(), max_iterations=5).run( query="执行接下来的分析", history=[], session_id="session-cross-job-recommendation", job_id="job-other", goal_ledger=ledger, ) ) assert outcome.action == "clarify" assert "当前 job" in outcome.message