mirror of
https://github.com/galaxyproject/galaxy.git
synced 2026-10-29 17:42:12 +08:00
Merge pull request #22070 from jmchilton/agent_testing
Replace mocked agent tests with static YAML backend for deterministic API/E2E testing
This commit is contained in:
@@ -155,7 +155,7 @@ onMounted(async () => {
|
||||
may not always be accurate.
|
||||
</span>
|
||||
</p>
|
||||
<BCard v-if="'tool_stderr' in jobDetails" class="mb-2">
|
||||
<BCard v-if="'tool_stderr' in jobDetails" class="mb-2" data-description="galaxy wizard card">
|
||||
<GalaxyWizard
|
||||
view="error"
|
||||
:query="jobDetails.tool_stderr ?? ''"
|
||||
|
||||
@@ -91,20 +91,29 @@ async function sendFeedback(value: "up" | "down") {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<GButton v-if="!queryResponse" class="w-100" variant="info" :disabled="busy" @click="submitQuery">
|
||||
<div data-description="galaxy wizard">
|
||||
<GButton
|
||||
v-if="!queryResponse"
|
||||
class="w-100"
|
||||
variant="info"
|
||||
:disabled="busy"
|
||||
data-description="galaxy wizard analyze button"
|
||||
@click="submitQuery">
|
||||
<span v-if="!busy"> Let our Help Wizard Figure it out! </span>
|
||||
<LoadingSpan v-else message="Thinking" />
|
||||
</GButton>
|
||||
<div :class="props.view == 'wizard' && 'mt-4'">
|
||||
<div v-if="busy">
|
||||
<div v-if="busy" data-description="galaxy wizard loading">
|
||||
<BSkeleton animation="wave" width="85%" />
|
||||
<BSkeleton animation="wave" width="55%" />
|
||||
<BSkeleton animation="wave" width="70%" />
|
||||
</div>
|
||||
<div v-else>
|
||||
<!-- eslint-disable-next-line vue/no-v-html -->
|
||||
<div class="chatResponse" v-html="renderMarkdown(queryResponse)" />
|
||||
<div
|
||||
class="chatResponse"
|
||||
data-description="galaxy wizard response"
|
||||
v-html="renderMarkdown(queryResponse)" />
|
||||
|
||||
<template v-if="errorMessage">
|
||||
<hr class="error-divider" />
|
||||
@@ -112,13 +121,17 @@ async function sendFeedback(value: "up" | "down") {
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<div v-if="queryResponse && !hasError" class="feedback-buttons mt-2">
|
||||
<div
|
||||
v-if="queryResponse && !hasError"
|
||||
class="feedback-buttons mt-2"
|
||||
data-description="galaxy wizard feedback">
|
||||
<hr class="w-100" />
|
||||
<h4>Was this answer helpful?</h4>
|
||||
<GButton
|
||||
color="green"
|
||||
:disabled="feedback !== null"
|
||||
:class="{ submitted: feedback === 'up' }"
|
||||
data-description="galaxy wizard feedback up"
|
||||
@click="sendFeedback('up')">
|
||||
<FontAwesomeIcon :icon="faThumbsUp" fixed-width />
|
||||
</GButton>
|
||||
@@ -126,11 +139,12 @@ async function sendFeedback(value: "up" | "down") {
|
||||
color="red"
|
||||
:disabled="feedback !== null"
|
||||
:class="{ submitted: feedback === 'down' }"
|
||||
data-description="galaxy wizard feedback down"
|
||||
@click="sendFeedback('down')">
|
||||
<FontAwesomeIcon :icon="faThumbsDown" fixed-width />
|
||||
</GButton>
|
||||
<i v-if="!feedback">This feedback helps us improve our responses.</i>
|
||||
<i v-else>Thank you for your feedback!</i>
|
||||
<i v-else data-description="galaxy wizard feedback ack">Thank you for your feedback!</i>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1454,6 +1454,68 @@ job_details:
|
||||
selector: '//td[@id="galaxy-tool-id"][normalize-space(text()) = "${tool_id}"]'
|
||||
tool_exit_code: '#exit-code'
|
||||
|
||||
chatgxy:
|
||||
selectors:
|
||||
activity: '#activity-chatgxy'
|
||||
_: '.chatgxy-container'
|
||||
header: '.chatgxy-header'
|
||||
new_chat_button: '.chatgxy-header .btn-outline-primary'
|
||||
delete_chat_button: '.chatgxy-header .btn-outline-danger'
|
||||
messages: '.chat-messages'
|
||||
input: '#chat-input'
|
||||
send_button: '.send-button'
|
||||
welcome_message: '.system-notice'
|
||||
query_cell: '.entry-query .query-text'
|
||||
response_cell: '.entry-response'
|
||||
response_content: '.entry-response .response-content'
|
||||
agent_indicator: '.entry-response .agent-indicator'
|
||||
loading: '.loading-entry'
|
||||
feedback_up: '.entry-response .feedback-btn[title="Helpful"]'
|
||||
feedback_down: '.entry-response .feedback-btn[title="Not helpful"]'
|
||||
feedback_ack: '.entry-response .feedback-ack'
|
||||
meta_tag: '.entry-response .meta-tag'
|
||||
|
||||
history_panel:
|
||||
selectors:
|
||||
_: '.activity-panel[data-description="ChatGXY"]'
|
||||
toggle_selection_mode: 'button[title="Select chats to delete"]'
|
||||
cancel_selection: 'button[title="Cancel selection"]'
|
||||
history_item: '[data-description="sidebar item"]'
|
||||
history_checkbox: '[data-description="sidebar item"] .history-checkbox'
|
||||
delete_selected_button: '.selection-toolbar .btn-danger'
|
||||
select_all_toggle: '.select-all-toggle'
|
||||
empty_message: '[data-description="sidebar list empty"]'
|
||||
|
||||
galaxy_wizard:
|
||||
selectors:
|
||||
_:
|
||||
selector: 'galaxy wizard'
|
||||
type: data-description
|
||||
analyze_button:
|
||||
selector: 'galaxy wizard analyze button'
|
||||
type: data-description
|
||||
loading:
|
||||
selector: 'galaxy wizard loading'
|
||||
type: data-description
|
||||
response:
|
||||
selector: 'galaxy wizard response'
|
||||
type: data-description
|
||||
feedback_section:
|
||||
selector: 'galaxy wizard feedback'
|
||||
type: data-description
|
||||
feedback_up:
|
||||
selector: 'galaxy wizard feedback up'
|
||||
type: data-description
|
||||
feedback_down:
|
||||
selector: 'galaxy wizard feedback down'
|
||||
type: data-description
|
||||
feedback_ack:
|
||||
selector: 'galaxy wizard feedback ack'
|
||||
type: data-description
|
||||
wizard_card:
|
||||
selector: 'galaxy wizard card'
|
||||
type: data-description
|
||||
|
||||
zip_import_wizard:
|
||||
selectors:
|
||||
_: '.zip-import-wizard'
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
"""Factory for building the appropriate AgentRegistry from config."""
|
||||
|
||||
import logging
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from .registry import (
|
||||
AgentRegistry,
|
||||
build_default_registry,
|
||||
)
|
||||
from .static_backend import StaticAgentRegistry
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from galaxy.config import GalaxyAppConfiguration
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def build_registry(config: "GalaxyAppConfiguration") -> AgentRegistry:
|
||||
"""Build an AgentRegistry based on config.
|
||||
|
||||
Uses StaticAgentRegistry when inference_services.static_responses is set,
|
||||
otherwise builds the default registry with real agents.
|
||||
"""
|
||||
inference_config = getattr(config, "inference_services", None) or {}
|
||||
static_responses = inference_config.get("static_responses") if isinstance(inference_config, dict) else None
|
||||
if static_responses:
|
||||
log.info(f"Static agent backend loaded: {static_responses}")
|
||||
return StaticAgentRegistry(static_responses)
|
||||
return build_default_registry(config)
|
||||
@@ -0,0 +1,126 @@
|
||||
"""Static agent backend for deterministic testing without LLM calls.
|
||||
|
||||
Provides StaticAgent and StaticAgentRegistry that return canned responses
|
||||
from YAML rules. Swap at the DI container level — no mocks, no pydantic-ai.
|
||||
"""
|
||||
|
||||
import re
|
||||
from typing import (
|
||||
Any,
|
||||
Optional,
|
||||
)
|
||||
|
||||
import yaml
|
||||
|
||||
from .base import (
|
||||
ActionSuggestion,
|
||||
AgentResponse,
|
||||
BaseGalaxyAgent,
|
||||
GalaxyAgentDependencies,
|
||||
)
|
||||
from .registry import AgentRegistry
|
||||
|
||||
|
||||
class StaticAgent(BaseGalaxyAgent):
|
||||
"""Agent that returns canned responses from YAML rules.
|
||||
|
||||
Subclasses BaseGalaxyAgent but skips pydantic-ai Agent creation entirely.
|
||||
Only process() is meaningful — all other BaseGalaxyAgent methods are stubs.
|
||||
"""
|
||||
|
||||
agent_type = "static" # overridden per-instance
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
agent_type_str: str,
|
||||
rules: list[dict[str, Any]],
|
||||
fallback: dict[str, Any],
|
||||
defaults: dict[str, Any],
|
||||
):
|
||||
# Intentionally skip super().__init__() — no pydantic-ai Agent needed.
|
||||
self.agent_type = agent_type_str
|
||||
self._rules = rules
|
||||
self._fallback = fallback
|
||||
self._defaults = defaults
|
||||
|
||||
def _create_agent(self):
|
||||
raise NotImplementedError("StaticAgent does not use pydantic-ai")
|
||||
|
||||
def get_system_prompt(self) -> str:
|
||||
return ""
|
||||
|
||||
async def process(self, query: str, context: Optional[dict[str, Any]] = None) -> AgentResponse:
|
||||
for rule in self._rules:
|
||||
if self._rule_matches(rule.get("match", {}), query, context):
|
||||
return self._make_response(rule["response"])
|
||||
return self._make_response(self._fallback)
|
||||
|
||||
def _rule_matches(self, match: dict[str, Any], query: str, context: Optional[dict[str, Any]]) -> bool:
|
||||
if "agent_type" in match and match["agent_type"] != self.agent_type:
|
||||
return False
|
||||
if "query" in match and not re.search(match["query"], query):
|
||||
return False
|
||||
if "context" in match:
|
||||
if not context:
|
||||
return False
|
||||
for field, pattern in match["context"].items():
|
||||
if field not in context or not re.search(pattern, str(context[field])):
|
||||
return False
|
||||
return True
|
||||
|
||||
def _make_response(self, resp: dict[str, Any]) -> AgentResponse:
|
||||
raw_suggestions = resp.get("suggestions", [])
|
||||
suggestions = [ActionSuggestion(**s) for s in raw_suggestions]
|
||||
return AgentResponse(
|
||||
content=resp.get("content", self._fallback.get("content", "")),
|
||||
confidence=resp.get("confidence", self._defaults.get("confidence", "medium")),
|
||||
agent_type=resp.get("agent_type", self.agent_type),
|
||||
suggestions=suggestions,
|
||||
metadata={**resp.get("metadata", {}), "static_backend": True},
|
||||
reasoning=resp.get("reasoning"),
|
||||
)
|
||||
|
||||
|
||||
class StaticAgentRegistry(AgentRegistry):
|
||||
"""Registry that returns StaticAgent instances from YAML config.
|
||||
|
||||
Subclasses AgentRegistry so it's type-compatible with the DI container.
|
||||
"""
|
||||
|
||||
def __init__(self, config_path: str):
|
||||
super().__init__()
|
||||
with open(config_path) as f:
|
||||
self._config: dict[str, Any] = yaml.safe_load(f) or {}
|
||||
self._rules: list[dict[str, Any]] = self._config.get("rules", [])
|
||||
self._fallback: dict[str, Any] = self._config.get("fallback", {})
|
||||
self._defaults: dict[str, Any] = self._config.get("defaults", {})
|
||||
|
||||
# Collect known agent_types from rules
|
||||
self._known_types: set[str] = set()
|
||||
for rule in self._rules:
|
||||
match = rule.get("match", {})
|
||||
if "agent_type" in match:
|
||||
self._known_types.add(match["agent_type"])
|
||||
|
||||
def get_agent(self, agent_type: str, deps: GalaxyAgentDependencies) -> StaticAgent:
|
||||
"""Return a StaticAgent that matches rules for this agent_type."""
|
||||
applicable = [r for r in self._rules if r.get("match", {}).get("agent_type", agent_type) == agent_type]
|
||||
return StaticAgent(agent_type, applicable, self._fallback, self._defaults)
|
||||
|
||||
def is_registered(self, agent_type: str) -> bool:
|
||||
return agent_type in self._known_types or bool(self._fallback)
|
||||
|
||||
def list_agents(self) -> list[str]:
|
||||
return sorted(self._known_types)
|
||||
|
||||
def get_agent_info(self, agent_type: str) -> dict[str, Any]:
|
||||
return {
|
||||
"agent_type": agent_type,
|
||||
"class_name": "StaticAgent",
|
||||
"module": "galaxy.agents.static_backend",
|
||||
"metadata": {"static_backend": True},
|
||||
"description": "Static test agent",
|
||||
}
|
||||
|
||||
def list_agent_info(self) -> list[dict[str, Any]]:
|
||||
return [self.get_agent_info(t) for t in sorted(self._known_types)]
|
||||
+3
-5
@@ -25,10 +25,8 @@ from galaxy import (
|
||||
jobs,
|
||||
tools,
|
||||
)
|
||||
from galaxy.agents.registry import (
|
||||
AgentRegistry,
|
||||
build_default_registry,
|
||||
)
|
||||
from galaxy.agents.factory import build_registry as build_agent_registry
|
||||
from galaxy.agents.registry import AgentRegistry
|
||||
from galaxy.carbon_emissions import get_carbon_intensity_entry
|
||||
from galaxy.celery.base_task import (
|
||||
GalaxyTaskBeforeStart,
|
||||
@@ -775,7 +773,7 @@ class UniverseApplication(StructuredApp, GalaxyManagerApplication, InstallationT
|
||||
self.queue_worker = self._register_singleton(GalaxyQueueWorker, GalaxyQueueWorker(self))
|
||||
|
||||
# AI agent registry and service
|
||||
agent_registry = build_default_registry(self.config)
|
||||
agent_registry = build_agent_registry(self.config)
|
||||
self._register_singleton(AgentRegistry, agent_registry)
|
||||
self._register_singleton(AgentService, AgentService(self.config, JobQueryManager(self), agent_registry))
|
||||
|
||||
|
||||
@@ -4091,6 +4091,9 @@ mapping:
|
||||
Agents and plugins inherit from 'default' configuration, which itself falls back to global ai_model/ai_api_key settings.
|
||||
All agents are enabled by default.
|
||||
Example: inference_services: { default: { model: gpt-4o-mini, temperature: 0.7 }, custom_tool: { enabled: false }, jupyterlite: { model: gpt-4o } }
|
||||
Set static_responses to a YAML file path to replace all LLM calls with
|
||||
deterministic responses for testing:
|
||||
inference_services: { static_responses: test/integration/static_agents.yml }
|
||||
|
||||
enable_tool_recommendations:
|
||||
type: bool
|
||||
|
||||
@@ -20,6 +20,13 @@ from galaxy.structured_app import StructuredApp
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _get_registry_type(config) -> str:
|
||||
inference_config = getattr(config, "inference_services", None) or {}
|
||||
if isinstance(inference_config, dict) and inference_config.get("static_responses"):
|
||||
return "static"
|
||||
return "default"
|
||||
|
||||
|
||||
class ConfigurationManager:
|
||||
"""Interface/service object for interacting with configuration and related data."""
|
||||
|
||||
@@ -232,6 +239,7 @@ class ConfigSerializer(base.ModelSerializer):
|
||||
"llm_api_configured": lambda item, key, **context: bool(
|
||||
item.ai_api_key or item.ai_api_base_url or getattr(item, "inference_services", None)
|
||||
),
|
||||
"llm_registry_type": lambda item, key, **context: _get_registry_type(item),
|
||||
"install_tool_dependencies": _use_config,
|
||||
"install_repository_dependencies": _use_config,
|
||||
"install_resolver_dependencies": _use_config,
|
||||
|
||||
@@ -1608,6 +1608,47 @@ class NavigatesGalaxy(HasDriverProxy[WaitType]):
|
||||
self.components.invocations.activity.wait_for_and_click()
|
||||
self.components.invocations.activity_expand.wait_for_and_click()
|
||||
|
||||
def navigate_to_chatgxy(self):
|
||||
self.home()
|
||||
self.components.chatgxy.activity.wait_for_and_click()
|
||||
|
||||
def chatgxy_ensure_new_chat(self):
|
||||
"""Ensure ChatGXY center panel shows an empty conversation."""
|
||||
chatgxy = self.components.chatgxy
|
||||
chatgxy._.wait_for_visible()
|
||||
if len(chatgxy.query_cell.all()) > 0 or len(chatgxy.response_content.all()) > 0:
|
||||
chatgxy.new_chat_button.wait_for_and_click()
|
||||
self._chatgxy_assert_chat_empty()
|
||||
|
||||
def chatgxy_send_message(self, text):
|
||||
"""Type a message, click send, and wait for the response to appear."""
|
||||
chatgxy = self.components.chatgxy
|
||||
chatgxy.input.wait_for_and_send_keys(text)
|
||||
chatgxy.send_button.wait_for_and_click()
|
||||
chatgxy.loading.wait_for_absent_or_hidden()
|
||||
chatgxy.response_content.wait_for_visible()
|
||||
|
||||
@retry_during_transitions
|
||||
def _chatgxy_assert_chat_empty(self):
|
||||
chatgxy = self.components.chatgxy
|
||||
assert len(chatgxy.query_cell.all()) == 0
|
||||
assert len(chatgxy.response_content.all()) == 0
|
||||
|
||||
def navigate_to_dataset_error(self, hid):
|
||||
"""Display a dataset and click the error tab."""
|
||||
self.display_dataset(hid)
|
||||
error_tab = self.wait_for_selector_clickable(
|
||||
".nav-item[title='View error information for this dataset'] > a.nav-link"
|
||||
)
|
||||
error_tab.click()
|
||||
|
||||
def galaxy_wizard_analyze(self):
|
||||
"""Click the wizard analyze button and wait for the response."""
|
||||
wizard = self.components.galaxy_wizard
|
||||
wizard.analyze_button.wait_for_and_click()
|
||||
# Button disappears once queryResponse is set (v-if="!queryResponse")
|
||||
wizard.analyze_button.wait_for_absent_or_hidden()
|
||||
|
||||
def navigate_to_pages(self):
|
||||
self.home()
|
||||
self.components.pages.activity.wait_for_and_click()
|
||||
|
||||
@@ -0,0 +1,133 @@
|
||||
"""API tests for Galaxy AI agents.
|
||||
|
||||
Tests run against any Galaxy with agents configured (static or real LLM).
|
||||
Assertions adapt based on llm_registry_type: strong for "static", weak for "default".
|
||||
|
||||
## Running (auto-started server with static backend):
|
||||
./run_tests.sh -api lib/galaxy_test/api/test_agents.py
|
||||
|
||||
## Running against an external Galaxy with a real LLM:
|
||||
./run_tests.sh -api lib/galaxy_test/api/test_agents.py \
|
||||
--external_url http://localhost:8080/ \
|
||||
--external_user_key YOUR_API_KEY
|
||||
"""
|
||||
|
||||
from galaxy_test.base.populators import (
|
||||
DatasetPopulator,
|
||||
skip_without_agents,
|
||||
)
|
||||
from ._framework import ApiTestCase
|
||||
|
||||
|
||||
class TestAgentsApi(ApiTestCase):
|
||||
dataset_populator: DatasetPopulator
|
||||
|
||||
def setUp(self):
|
||||
super().setUp()
|
||||
self.dataset_populator = DatasetPopulator(self.galaxy_interactor)
|
||||
config = self._get("configuration").json()
|
||||
self._registry_type = config.get("llm_registry_type", "default")
|
||||
|
||||
@property
|
||||
def _is_static(self) -> bool:
|
||||
return self._registry_type == "static"
|
||||
|
||||
def _query_agent(self, query: str, agent_type: str = "auto") -> dict:
|
||||
response = self._post(
|
||||
"ai/agents/query",
|
||||
data={"query": query, "agent_type": agent_type},
|
||||
json=True,
|
||||
)
|
||||
self._assert_status_code_is_ok(response)
|
||||
return response.json()
|
||||
|
||||
@skip_without_agents
|
||||
def test_configuration_reports_agents(self):
|
||||
"""GET /api/configuration reports llm_api_configured."""
|
||||
config = self._get("configuration").json()
|
||||
assert config.get("llm_api_configured") is True
|
||||
if self._is_static:
|
||||
assert config.get("llm_registry_type") == "static"
|
||||
|
||||
@skip_without_agents
|
||||
def test_list_agents(self):
|
||||
"""GET /api/ai/agents returns agent list."""
|
||||
response = self._get("ai/agents")
|
||||
self._assert_status_code_is_ok(response)
|
||||
data = response.json()
|
||||
assert "agents" in data
|
||||
agent_types = [a["agent_type"] for a in data["agents"]]
|
||||
assert len(agent_types) > 0
|
||||
if self._is_static:
|
||||
assert "router" in agent_types
|
||||
assert "error_analysis" in agent_types
|
||||
|
||||
@skip_without_agents
|
||||
def test_list_agents_includes_custom_tool(self):
|
||||
"""GET /api/ai/agents includes custom_tool agent."""
|
||||
response = self._get("ai/agents")
|
||||
self._assert_status_code_is_ok(response)
|
||||
agent_types = [a["agent_type"] for a in response.json()["agents"]]
|
||||
assert "custom_tool" in agent_types
|
||||
|
||||
@skip_without_agents
|
||||
def test_chat_greeting(self):
|
||||
"""Query with greeting returns appropriate response."""
|
||||
data = self._query_agent("Hello!")
|
||||
content = data["response"]["content"]
|
||||
assert len(content) > 0
|
||||
if self._is_static:
|
||||
assert "Hello" in content
|
||||
|
||||
@skip_without_agents
|
||||
def test_chat_domain_query(self):
|
||||
"""Query about RNA-seq returns domain-specific response."""
|
||||
data = self._query_agent("How do I analyze RNA-seq data?")
|
||||
content = data["response"]["content"]
|
||||
assert len(content) > 0
|
||||
if self._is_static:
|
||||
assert "HISAT2" in content
|
||||
|
||||
@skip_without_agents
|
||||
def test_chat_fallback(self):
|
||||
"""Generic query gets a response."""
|
||||
data = self._query_agent("Tell me about Galaxy")
|
||||
content = data["response"]["content"]
|
||||
assert len(content) > 0
|
||||
if self._is_static:
|
||||
assert "Galaxy" in content
|
||||
|
||||
@skip_without_agents
|
||||
def test_response_metadata(self):
|
||||
"""Agent responses include metadata."""
|
||||
data = self._query_agent("Hello!")
|
||||
metadata = data["response"].get("metadata", {})
|
||||
assert isinstance(metadata, dict)
|
||||
if self._is_static:
|
||||
assert metadata.get("static_backend") is True
|
||||
|
||||
@skip_without_agents
|
||||
def test_custom_tool_agent(self):
|
||||
"""custom_tool agent returns tool metadata."""
|
||||
data = self._query_agent("Create a tool that counts lines", agent_type="custom_tool")
|
||||
resp = data["response"]
|
||||
assert "metadata" in resp
|
||||
assert "tool_id" in resp["metadata"]
|
||||
if self._is_static:
|
||||
assert resp["metadata"]["tool_id"] == "line-counter"
|
||||
assert "wc -l" in resp["metadata"]["tool_yaml"]
|
||||
suggestions = resp.get("suggestions", [])
|
||||
assert len(suggestions) >= 1
|
||||
save = [s for s in suggestions if s["action_type"] == "save_tool"]
|
||||
assert len(save) == 1
|
||||
assert save[0]["parameters"]["tool_id"] == "line-counter"
|
||||
assert save[0]["parameters"]["tool_yaml"]
|
||||
|
||||
@skip_without_agents
|
||||
def test_error_analysis_agent(self):
|
||||
"""error_analysis agent returns error-related response."""
|
||||
data = self._query_agent("My job failed with exit code 1", agent_type="error_analysis")
|
||||
content = data["response"]["content"]
|
||||
assert len(content) > 0
|
||||
if self._is_static:
|
||||
assert "error" in content.lower() or "configuration" in content.lower()
|
||||
@@ -0,0 +1,67 @@
|
||||
defaults:
|
||||
confidence: medium
|
||||
|
||||
rules:
|
||||
- match:
|
||||
agent_type: router
|
||||
query: "(?i)hello|hi|hey"
|
||||
response:
|
||||
content: "Hello! I'm Galaxy's AI assistant. How can I help?"
|
||||
confidence: high
|
||||
agent_type: router
|
||||
|
||||
- match:
|
||||
agent_type: router
|
||||
query: "(?i)rna.?seq"
|
||||
response:
|
||||
content: "Galaxy has several RNA-seq tools including HISAT2 and STAR."
|
||||
confidence: high
|
||||
agent_type: router
|
||||
|
||||
- match:
|
||||
agent_type: custom_tool
|
||||
response:
|
||||
content: "I've created a Line Counter tool that counts lines in a file."
|
||||
confidence: high
|
||||
agent_type: custom_tool
|
||||
metadata:
|
||||
tool_id: line-counter
|
||||
tool_yaml: |
|
||||
class: GalaxyUserTool
|
||||
id: line-counter
|
||||
name: Line Counter
|
||||
version: "1.0.0"
|
||||
description: Counts lines in a file
|
||||
container: ubuntu:latest
|
||||
shell_command: "wc -l < $(inputs.input_file.path) > output.txt"
|
||||
suggestions:
|
||||
- action_type: save_tool
|
||||
description: "Save the Line Counter tool to your Galaxy instance"
|
||||
parameters:
|
||||
tool_id: line-counter
|
||||
tool_yaml: |
|
||||
class: GalaxyUserTool
|
||||
id: line-counter
|
||||
name: Line Counter
|
||||
version: "1.0.0"
|
||||
confidence: high
|
||||
priority: 1
|
||||
|
||||
- match:
|
||||
agent_type: error_analysis
|
||||
response:
|
||||
content: "This error appears to be a tool configuration issue."
|
||||
confidence: high
|
||||
agent_type: error_analysis
|
||||
|
||||
- match:
|
||||
agent_type: router
|
||||
response:
|
||||
content: "I can help with Galaxy workflows, tools, and data analysis."
|
||||
confidence: medium
|
||||
agent_type: router
|
||||
|
||||
fallback:
|
||||
content: "Static backend: no matching rule."
|
||||
confidence: low
|
||||
agent_type: unknown
|
||||
@@ -226,6 +226,19 @@ def skip_without_tool(tool_id: str):
|
||||
return method_wrapper
|
||||
|
||||
|
||||
def skip_without_agents(method):
|
||||
@wraps(method)
|
||||
def wrapped_method(api_test_case, *args, **kwd):
|
||||
interactor = api_test_case.anonymous_galaxy_interactor
|
||||
resp = interactor.get("configuration")
|
||||
api_asserts.assert_status_code_is_ok(resp)
|
||||
if not resp.json().get("llm_api_configured", False):
|
||||
raise unittest.SkipTest("Agents not available")
|
||||
return method(api_test_case, *args, **kwd)
|
||||
|
||||
return wrapped_method
|
||||
|
||||
|
||||
def skip_without_asgi(method):
|
||||
@wraps(method)
|
||||
def wrapped_method(api_test_case: HasAnonymousGalaxyInteractor, *args, **kwd):
|
||||
|
||||
@@ -293,6 +293,13 @@ backends:
|
||||
# TODO: read from Galaxy's config API.
|
||||
os.environ["GALAXY_TEST_TOOL_DEPENDENCY_DIR"] = tool_dependency_dir or os.path.join(tmpdir, "dependencies")
|
||||
|
||||
# Static agent backend for deterministic AI agent testing
|
||||
static_agents_path = os.path.realpath(
|
||||
os.path.join(os.path.dirname(__file__), "..", "base", "data", "static_agents.yml")
|
||||
)
|
||||
if os.path.exists(static_agents_path):
|
||||
config["inference_services"] = {"static_responses": static_agents_path}
|
||||
|
||||
return config
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,120 @@
|
||||
"""E2E tests for the ChatGXY conversational AI interface.
|
||||
|
||||
Uses the static agent backend for deterministic assertions — no LLM calls.
|
||||
Skipped when agents are not configured (skip_without_agents decorator).
|
||||
"""
|
||||
|
||||
from galaxy_test.base.populators import skip_without_agents
|
||||
from .framework import (
|
||||
retry_assertion_during_transitions,
|
||||
selenium_test,
|
||||
SeleniumTestCase,
|
||||
)
|
||||
|
||||
|
||||
class TestChatGXY(SeleniumTestCase):
|
||||
ensure_registered = True
|
||||
|
||||
@skip_without_agents
|
||||
@selenium_test
|
||||
def test_chat_greeting_flow(self):
|
||||
"""Activity visible, send greeting, get response, give feedback, check metadata."""
|
||||
self.home()
|
||||
chatgxy = self.components.chatgxy
|
||||
|
||||
# Activity bar icon visible
|
||||
chatgxy.activity.wait_for_visible()
|
||||
|
||||
# Navigate, start fresh
|
||||
self.navigate_to_chatgxy()
|
||||
self.chatgxy_ensure_new_chat()
|
||||
|
||||
# Send greeting
|
||||
self.chatgxy_send_message("Hello!")
|
||||
|
||||
# Verify query cell and response
|
||||
assert chatgxy.query_cell.wait_for_text() == "Hello!"
|
||||
|
||||
@retry_assertion_during_transitions
|
||||
def assert_response():
|
||||
assert "Hello" in chatgxy.response_content.wait_for_text()
|
||||
|
||||
assert_response()
|
||||
|
||||
# Agent indicator and metadata tags present
|
||||
chatgxy.agent_indicator.wait_for_visible()
|
||||
assert len(chatgxy.meta_tag.all()) >= 1
|
||||
|
||||
# Feedback
|
||||
chatgxy.feedback_up.wait_for_and_click()
|
||||
assert "Thanks" in chatgxy.feedback_ack.wait_for_text()
|
||||
|
||||
@skip_without_agents
|
||||
@selenium_test
|
||||
def test_multi_turn_and_new_chat(self):
|
||||
"""Two queries build up conversation, 'New' resets it."""
|
||||
self.navigate_to_chatgxy()
|
||||
self.chatgxy_ensure_new_chat()
|
||||
chatgxy = self.components.chatgxy
|
||||
|
||||
self.chatgxy_send_message("Hello!")
|
||||
self.chatgxy_send_message("How do I analyze RNA-seq data?")
|
||||
|
||||
@retry_assertion_during_transitions
|
||||
def assert_two_exchanges():
|
||||
assert len(chatgxy.query_cell.all()) == 2
|
||||
responses = chatgxy.response_content.all()
|
||||
assert len(responses) >= 2
|
||||
# Second response should be domain-specific
|
||||
assert "HISAT2" in responses[-1].text or "RNA-seq" in responses[-1].text
|
||||
|
||||
assert_two_exchanges()
|
||||
|
||||
# New chat resets
|
||||
chatgxy.new_chat_button.wait_for_and_click()
|
||||
self._chatgxy_assert_chat_empty()
|
||||
|
||||
@skip_without_agents
|
||||
@selenium_test
|
||||
def test_delete_chats_via_selection(self):
|
||||
"""Create two chats, select both in history panel, bulk delete."""
|
||||
# Clear any existing chat history so test is deterministic
|
||||
self.api_delete("chat/history")
|
||||
|
||||
self.navigate_to_chatgxy()
|
||||
chatgxy = self.components.chatgxy
|
||||
history = chatgxy.history_panel
|
||||
|
||||
# Create first chat
|
||||
self.chatgxy_ensure_new_chat()
|
||||
self.chatgxy_send_message("Hello!")
|
||||
|
||||
# Create second chat
|
||||
self.chatgxy_ensure_new_chat()
|
||||
self.chatgxy_send_message("How do I analyze RNA-seq data?")
|
||||
|
||||
# Switch sidebar away from chatgxy then back to force ChatHistoryPanel remount
|
||||
self.components.tools.activity.wait_for_and_click()
|
||||
chatgxy.activity.wait_for_and_click()
|
||||
history._.wait_for_visible()
|
||||
|
||||
# Wait for history items to appear
|
||||
@retry_assertion_during_transitions
|
||||
def assert_history_has_items():
|
||||
assert len(history.history_item.all()) >= 2
|
||||
|
||||
assert_history_has_items()
|
||||
|
||||
# Enter selection mode
|
||||
history.toggle_selection_mode.wait_for_and_click()
|
||||
|
||||
# Click first two history items to select them
|
||||
items = history.history_item.all()
|
||||
items[0].click()
|
||||
items[1].click()
|
||||
|
||||
# Delete selected
|
||||
history.delete_selected_button.wait_for_and_click()
|
||||
|
||||
# Verify all items were removed (panel should be empty)
|
||||
history.empty_message.wait_for_visible()
|
||||
@@ -0,0 +1,69 @@
|
||||
"""E2E tests for the GalaxyWizard inline error analysis widget.
|
||||
|
||||
Uses the static agent backend for deterministic assertions — no LLM calls.
|
||||
Skipped when agents are not configured (skip_without_agents decorator).
|
||||
"""
|
||||
|
||||
from galaxy_test.base.populators import skip_without_agents
|
||||
from .framework import (
|
||||
managed_history,
|
||||
retry_assertion_during_transitions,
|
||||
selenium_test,
|
||||
SeleniumTestCase,
|
||||
)
|
||||
|
||||
|
||||
class TestGalaxyWizard(SeleniumTestCase):
|
||||
ensure_registered = True
|
||||
|
||||
def create_failed_dataset(self):
|
||||
"""Run detect_errors tool with stderr output to produce a failed dataset."""
|
||||
history_id = self.current_history_id()
|
||||
inputs = {
|
||||
"stdoutmsg": "",
|
||||
"stderrmsg": "error: tool configuration failure detected",
|
||||
"exit_code": "6",
|
||||
}
|
||||
response = self.dataset_populator.run_tool("detect_errors", inputs, history_id)
|
||||
self.dataset_populator.wait_for_history(history_id, assert_ok=False)
|
||||
failed_hid = response["outputs"][0]["hid"]
|
||||
return history_id, failed_hid
|
||||
|
||||
@skip_without_agents
|
||||
@selenium_test
|
||||
@managed_history
|
||||
def test_wizard_error_analysis_flow(self):
|
||||
"""Create failed dataset, analyze error, verify response and feedback."""
|
||||
# Setup: create a failed dataset via API
|
||||
history_id, failed_hid = self.create_failed_dataset()
|
||||
self.history_panel_wait_for_hid_state(failed_hid, "error")
|
||||
self.screenshot("galaxy_wizard_error_dataset_in_history")
|
||||
|
||||
# Navigate to error view
|
||||
self.navigate_to_dataset_error(failed_hid)
|
||||
wizard = self.components.galaxy_wizard
|
||||
|
||||
# Wizard section visible (agents configured)
|
||||
wizard.analyze_button.wait_for_visible()
|
||||
self.screenshot("galaxy_wizard_before_analyze")
|
||||
|
||||
# Click analyze, wait for response
|
||||
self.galaxy_wizard_analyze()
|
||||
self.screenshot("galaxy_wizard_response_received")
|
||||
|
||||
# Verify response content from static backend
|
||||
@retry_assertion_during_transitions
|
||||
def assert_response():
|
||||
assert "tool configuration issue" in wizard.response.wait_for_text()
|
||||
|
||||
assert_response()
|
||||
|
||||
# Feedback
|
||||
wizard.feedback_up.wait_for_and_click()
|
||||
|
||||
@retry_assertion_during_transitions
|
||||
def assert_feedback():
|
||||
assert "Thank you" in wizard.feedback_ack.wait_for_text()
|
||||
|
||||
assert_feedback()
|
||||
self.screenshot("galaxy_wizard_feedback_submitted")
|
||||
@@ -15,7 +15,7 @@
|
||||
echo '$stdoutmsg' &&
|
||||
#end if
|
||||
#if str($stderrmsg) != ""
|
||||
>2& echo '$stderrmsg' &&
|
||||
>&2 echo '$stderrmsg' &&
|
||||
#end if
|
||||
sh -c 'exit $exit_code'
|
||||
]]>
|
||||
@@ -37,10 +37,10 @@ sh -c 'exit $exit_code'
|
||||
<param name="exit_code" value="3" />
|
||||
<assert_stdout>
|
||||
<has_line line="Log: some program message of interest"/>
|
||||
<has_line line="Warning: Low space on device"/>
|
||||
</assert_stdout>
|
||||
<assert_stderr>
|
||||
<has_line line="Warning: Exit code 3 (Low disk space)"/>
|
||||
<has_line line="Warning: Low space on device"/>
|
||||
</assert_stderr>
|
||||
</test>
|
||||
<!-- unccessful run (fatal exit code + warning in output messages are detected) -->
|
||||
|
||||
@@ -1,43 +1,19 @@
|
||||
"""Test Galaxy AI agents API and functionality.
|
||||
"""Test Galaxy AI agents API with live LLM.
|
||||
|
||||
This module contains two test suites:
|
||||
1. Mocked tests - Deterministic tests with mocked LLM responses (always run)
|
||||
2. Live LLM tests - Integration tests requiring configured LLM (optional, marked with @pytest.mark.requires_llm)
|
||||
Requires a configured LLM — skipped unless GALAXY_TEST_ENABLE_LIVE_LLM=1.
|
||||
For deterministic tests without LLM, see test_static_agent_backend.py.
|
||||
|
||||
## Running the tests:
|
||||
|
||||
### API tests (Galaxy test instance auto-configured):
|
||||
# Run mocked API tests (Galaxy test framework handles setup):
|
||||
pytest test/integration/test_agents.py::TestAgentsApiMocked -v
|
||||
|
||||
# Run live LLM API tests:
|
||||
GALAXY_TEST_ENABLE_LIVE_LLM=1 pytest test/integration/test_agents.py::TestAgentsApiLiveLLM -v
|
||||
|
||||
### Configuration for live API tests (TestAgentsApiLiveLLM):
|
||||
## Running:
|
||||
export GALAXY_TEST_AI_API_KEY="your-api-key"
|
||||
export GALAXY_TEST_AI_MODEL="llama-4-scout"
|
||||
export GALAXY_TEST_AI_API_BASE_URL="http://localhost:4000/v1/"
|
||||
export GALAXY_TEST_ENABLE_LIVE_LLM=1
|
||||
|
||||
### Configuration for live unit tests (TestAgentUnitLiveLLM):
|
||||
export GALAXY_AI_API_KEY="your-api-key"
|
||||
export GALAXY_AI_MODEL="llama-4-scout"
|
||||
export GALAXY_AI_API_BASE_URL="http://localhost:4000/v1/"
|
||||
export GALAXY_TEST_ENABLE_LIVE_LLM=1
|
||||
pytest test/integration/test_agents.py -v
|
||||
"""
|
||||
|
||||
import logging
|
||||
import os
|
||||
from unittest.mock import (
|
||||
AsyncMock,
|
||||
MagicMock,
|
||||
patch,
|
||||
)
|
||||
|
||||
from galaxy.agents import GalaxyAgentDependencies
|
||||
from galaxy.agents.error_analysis import ErrorAnalysisResult
|
||||
from galaxy.agents.registry import build_default_registry
|
||||
from galaxy.tool_util_models import UserToolSource
|
||||
from galaxy.util.unittest_utils import pytestmark_live_llm
|
||||
from galaxy_test.base.populators import (
|
||||
DatasetPopulator,
|
||||
@@ -70,203 +46,6 @@ class AgentIntegrationTestCase(IntegrationTestCase):
|
||||
config["ai_model"] = ai_model
|
||||
|
||||
|
||||
_registry = build_default_registry()
|
||||
|
||||
|
||||
def _create_deps_with_mock_model(self, trans, user):
|
||||
"""Replacement for AgentService.create_dependencies that injects a mock model_factory."""
|
||||
toolbox = trans.app.toolbox if hasattr(trans, "app") and hasattr(trans.app, "toolbox") else None
|
||||
return GalaxyAgentDependencies(
|
||||
trans=trans,
|
||||
user=user,
|
||||
config=self.config,
|
||||
job_manager=self.job_manager,
|
||||
toolbox=toolbox,
|
||||
get_agent=_registry.get_agent,
|
||||
model_factory=lambda: MagicMock(),
|
||||
)
|
||||
|
||||
|
||||
class TestAgentsApiMocked(AgentIntegrationTestCase):
|
||||
"""Test the Galaxy AI agents API with mocked LLM responses.
|
||||
|
||||
These tests use mocked LLM responses for deterministic testing.
|
||||
They always run in CI and don't require any LLM configuration.
|
||||
"""
|
||||
|
||||
def setUp(self):
|
||||
super().setUp()
|
||||
self.dataset_populator = DatasetPopulator(self.galaxy_interactor)
|
||||
self.workflow_populator = WorkflowPopulator(self.galaxy_interactor)
|
||||
|
||||
def test_list_agents(self):
|
||||
"""Test listing available agents (no LLM needed)."""
|
||||
response = self._get("ai/agents")
|
||||
self._assert_status_code_is_ok(response)
|
||||
data = response.json()
|
||||
assert "agents" in data
|
||||
agents = data["agents"]
|
||||
assert len(agents) > 0
|
||||
# Check that core agents are registered
|
||||
agent_types = [a["agent_type"] for a in agents]
|
||||
assert "router" in agent_types
|
||||
assert "custom_tool" in agent_types
|
||||
assert "error_analysis" in agent_types
|
||||
|
||||
@patch("galaxy.managers.agents.AgentService.create_dependencies", _create_deps_with_mock_model)
|
||||
@patch("galaxy.agents.router.Agent")
|
||||
def test_query_agent_auto_routing_mocked(self, mock_router_agent_class):
|
||||
"""Test automatic agent routing with mocked LLM.
|
||||
|
||||
With the new router architecture, the router uses output functions
|
||||
and returns the final response directly (either answering or handing
|
||||
off to specialists internally).
|
||||
"""
|
||||
# Set up mock router agent
|
||||
mock_router_agent = AsyncMock()
|
||||
mock_router_agent_class.return_value = mock_router_agent
|
||||
|
||||
# Mock router response - now returns string directly
|
||||
async def mock_router_run(query, *args, **kwargs):
|
||||
result = MagicMock()
|
||||
if "BWA" in query or "tool" in query.lower():
|
||||
# Simulate what custom_tool handoff would return
|
||||
result.output = "I've created a BWA-MEM tool for paired-end reads. The tool definition includes inputs for reference and read files."
|
||||
else:
|
||||
# Direct response from router
|
||||
result.output = "I'm Galaxy's AI assistant. How can I help you today?"
|
||||
return result
|
||||
|
||||
mock_router_agent.run = mock_router_run
|
||||
|
||||
response = self._post(
|
||||
"ai/agents/query",
|
||||
data={
|
||||
"query": "Create a BWA-MEM tool for paired-end reads",
|
||||
"agent_type": "auto",
|
||||
},
|
||||
json=True,
|
||||
)
|
||||
self._assert_status_code_is_ok(response)
|
||||
data = response.json()
|
||||
# Router now returns content in the response object
|
||||
assert "response" in data
|
||||
assert "content" in data["response"]
|
||||
assert "BWA" in data["response"]["content"] or len(data["response"]["content"]) > 0
|
||||
|
||||
@patch("galaxy.managers.agents.AgentService.create_dependencies", _create_deps_with_mock_model)
|
||||
@patch("galaxy.agents.custom_tool.Agent")
|
||||
def test_query_custom_tool_agent_mocked(self, mock_agent_class):
|
||||
"""Test the custom tool agent with mocked LLM."""
|
||||
mock_agent = AsyncMock()
|
||||
mock_agent_class.return_value = mock_agent
|
||||
|
||||
# Mock tool creation with UserToolSource
|
||||
mock_tool = UserToolSource(
|
||||
**{
|
||||
"class": "GalaxyUserTool",
|
||||
"id": "line-counter",
|
||||
"name": "Line Counter",
|
||||
"version": "1.0.0",
|
||||
"description": "Counts lines in a file",
|
||||
"container": "ubuntu:latest",
|
||||
"shell_command": "wc -l < $(inputs.input_file.path) > output.txt",
|
||||
"inputs": [],
|
||||
"outputs": [],
|
||||
}
|
||||
)
|
||||
|
||||
async def mock_run(*args, **kwargs):
|
||||
result = MagicMock()
|
||||
result.output = mock_tool
|
||||
return result
|
||||
|
||||
mock_agent.run = mock_run
|
||||
|
||||
response = self._post(
|
||||
"ai/agents/query",
|
||||
data={
|
||||
"query": "Create a simple tool that counts lines in a file",
|
||||
"agent_type": "custom_tool",
|
||||
},
|
||||
json=True,
|
||||
)
|
||||
self._assert_status_code_is_ok(response)
|
||||
data = response.json()
|
||||
# Response structure is {'response': {..., 'metadata': {...}}, 'routing_info': ..., ...}
|
||||
assert "response" in data
|
||||
assert "metadata" in data["response"]
|
||||
assert data["response"]["metadata"]["tool_id"] == "line-counter"
|
||||
assert "wc -l" in data["response"]["metadata"]["tool_yaml"]
|
||||
|
||||
# Verify suggestions are present and valid
|
||||
suggestions = data["response"].get("suggestions", [])
|
||||
assert len(suggestions) >= 1, "Custom tool should return at least one suggestion"
|
||||
# Should have a SAVE_TOOL suggestion with required parameters
|
||||
save_suggestions = [s for s in suggestions if s["action_type"] == "save_tool"]
|
||||
assert len(save_suggestions) == 1, "Should have exactly one SAVE_TOOL suggestion"
|
||||
assert save_suggestions[0]["parameters"].get("tool_yaml"), "SAVE_TOOL must have tool_yaml"
|
||||
assert save_suggestions[0]["parameters"].get("tool_id"), "SAVE_TOOL must have tool_id"
|
||||
|
||||
@patch("galaxy.managers.agents.AgentService.create_dependencies", _create_deps_with_mock_model)
|
||||
@patch("galaxy.agents.error_analysis.Agent")
|
||||
def test_query_error_analysis_agent_mocked(self, mock_agent_class):
|
||||
"""Test the error analysis agent with mocked LLM."""
|
||||
mock_agent = AsyncMock()
|
||||
mock_agent_class.return_value = mock_agent
|
||||
|
||||
# Mock error analysis
|
||||
async def mock_run(*args, **kwargs):
|
||||
result = MagicMock()
|
||||
mock_analysis = ErrorAnalysisResult(
|
||||
error_category="tool_configuration",
|
||||
error_severity="medium",
|
||||
likely_cause="The command 'samtools' was not found in PATH",
|
||||
solution_steps=[
|
||||
"Install samtools using conda",
|
||||
"Check tool dependencies",
|
||||
"Verify container configuration",
|
||||
],
|
||||
confidence="high",
|
||||
)
|
||||
result.output = mock_analysis
|
||||
return result
|
||||
|
||||
mock_agent.run = mock_run
|
||||
|
||||
# Don't pass a job_id - the agent handles missing job context gracefully
|
||||
# and we're testing the mocked LLM response, not job lookup
|
||||
response = self._post(
|
||||
"ai/agents/query",
|
||||
data={
|
||||
"query": "Why did my job fail? stderr shows: command not found: samtools",
|
||||
"agent_type": "error_analysis",
|
||||
},
|
||||
json=True,
|
||||
)
|
||||
self._assert_status_code_is_ok(response)
|
||||
data = response.json()
|
||||
# Response structure is {'response': {..., 'content': ...}, ...}
|
||||
assert "response" in data
|
||||
assert "content" in data["response"]
|
||||
# Should mention the error type or solution
|
||||
content = data["response"]["content"].lower()
|
||||
assert "command" in content or "samtools" in content or "not found" in content
|
||||
|
||||
# Suggestions are optional - only returned for actionable items like CONTACT_SUPPORT
|
||||
# In this mock, requires_admin=False, so no suggestions expected
|
||||
suggestions = data["response"].get("suggestions", [])
|
||||
for suggestion in suggestions:
|
||||
assert "action_type" in suggestion
|
||||
assert "description" in suggestion
|
||||
assert "confidence" in suggestion
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# LIVE LLM TEST SUITE - Requires configured LLM
|
||||
# ============================================================================
|
||||
|
||||
|
||||
@pytestmark_live_llm
|
||||
class TestAgentsApiLiveLLM(AgentIntegrationTestCase):
|
||||
"""Test Galaxy AI agents API with real LLM.
|
||||
|
||||
@@ -0,0 +1,395 @@
|
||||
"""Unit tests for the static agent backend (YAML rule matching)."""
|
||||
|
||||
import os
|
||||
import tempfile
|
||||
|
||||
import pytest
|
||||
import yaml
|
||||
|
||||
from galaxy.agents.base import (
|
||||
AgentResponse,
|
||||
BaseGalaxyAgent,
|
||||
ConfidenceLevel,
|
||||
)
|
||||
from galaxy.agents.static_backend import (
|
||||
StaticAgent,
|
||||
StaticAgentRegistry,
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def sample_config():
|
||||
return {
|
||||
"defaults": {"confidence": "medium"},
|
||||
"rules": [
|
||||
{
|
||||
"match": {"agent_type": "router", "query": "(?i)hello|hi"},
|
||||
"response": {
|
||||
"content": "Hello! How can I help?",
|
||||
"confidence": "high",
|
||||
"agent_type": "router",
|
||||
},
|
||||
},
|
||||
{
|
||||
"match": {"agent_type": "router", "query": "(?i)rna.?seq"},
|
||||
"response": {
|
||||
"content": "Galaxy has RNA-seq tools including HISAT2.",
|
||||
"confidence": "high",
|
||||
"agent_type": "router",
|
||||
},
|
||||
},
|
||||
{
|
||||
"match": {"agent_type": "error_analysis"},
|
||||
"response": {
|
||||
"content": "This error is a tool configuration issue.",
|
||||
"confidence": "high",
|
||||
"agent_type": "error_analysis",
|
||||
},
|
||||
},
|
||||
{
|
||||
"match": {"agent_type": "router"},
|
||||
"response": {
|
||||
"content": "I can help with Galaxy workflows and tools.",
|
||||
"confidence": "medium",
|
||||
"agent_type": "router",
|
||||
},
|
||||
},
|
||||
],
|
||||
"fallback": {
|
||||
"content": "Static backend: no matching rule.",
|
||||
"confidence": "low",
|
||||
"agent_type": "unknown",
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def yaml_config_path(sample_config):
|
||||
with tempfile.NamedTemporaryFile(mode="w", suffix=".yml", delete=False) as f:
|
||||
yaml.dump(sample_config, f)
|
||||
path = f.name
|
||||
yield path
|
||||
os.unlink(path)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def static_registry(yaml_config_path):
|
||||
return StaticAgentRegistry(yaml_config_path)
|
||||
|
||||
|
||||
# --- StaticAgent tests ---
|
||||
|
||||
|
||||
class TestStaticAgent:
|
||||
def _make_agent(self, agent_type, rules, fallback=None, defaults=None):
|
||||
fallback = fallback or {"content": "fallback", "confidence": "low", "agent_type": "unknown"}
|
||||
defaults = defaults or {"confidence": "medium"}
|
||||
return StaticAgent(agent_type, rules, fallback, defaults)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_exact_agent_type_match(self):
|
||||
rules = [
|
||||
{
|
||||
"match": {"agent_type": "router"},
|
||||
"response": {"content": "Router response", "confidence": "high", "agent_type": "router"},
|
||||
}
|
||||
]
|
||||
agent = self._make_agent("router", rules)
|
||||
response = await agent.process("anything")
|
||||
assert response.content == "Router response"
|
||||
assert response.confidence == ConfidenceLevel.HIGH
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_query_regex_match(self):
|
||||
rules = [
|
||||
{
|
||||
"match": {"query": "(?i)hello|hi"},
|
||||
"response": {"content": "Greeting!", "confidence": "high", "agent_type": "router"},
|
||||
}
|
||||
]
|
||||
agent = self._make_agent("router", rules)
|
||||
response = await agent.process("Hello there")
|
||||
assert response.content == "Greeting!"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_query_regex_case_insensitive(self):
|
||||
rules = [
|
||||
{
|
||||
"match": {"query": "(?i)hello"},
|
||||
"response": {"content": "Hi!", "confidence": "high", "agent_type": "router"},
|
||||
}
|
||||
]
|
||||
agent = self._make_agent("router", rules)
|
||||
response = await agent.process("HELLO WORLD")
|
||||
assert response.content == "Hi!"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_combined_match(self):
|
||||
"""Both agent_type and query must match (AND semantics)."""
|
||||
rules = [
|
||||
{
|
||||
"match": {"agent_type": "router", "query": "(?i)hello"},
|
||||
"response": {"content": "Router hello", "confidence": "high", "agent_type": "router"},
|
||||
}
|
||||
]
|
||||
agent = self._make_agent("router", rules)
|
||||
# Both match
|
||||
response = await agent.process("hello")
|
||||
assert response.content == "Router hello"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_combined_match_query_mismatch(self):
|
||||
"""agent_type matches but query doesn't — should fall through."""
|
||||
rules = [
|
||||
{
|
||||
"match": {"agent_type": "router", "query": "(?i)hello"},
|
||||
"response": {"content": "Router hello", "confidence": "high", "agent_type": "router"},
|
||||
}
|
||||
]
|
||||
agent = self._make_agent("router", rules)
|
||||
response = await agent.process("goodbye")
|
||||
assert response.content == "fallback"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_fallthrough_to_catchall(self):
|
||||
"""Specific rule fails, generic catchall matches."""
|
||||
rules = [
|
||||
{
|
||||
"match": {"agent_type": "router", "query": "(?i)hello"},
|
||||
"response": {"content": "Hello!", "confidence": "high", "agent_type": "router"},
|
||||
},
|
||||
{
|
||||
"match": {"agent_type": "router"},
|
||||
"response": {"content": "Generic router", "confidence": "medium", "agent_type": "router"},
|
||||
},
|
||||
]
|
||||
agent = self._make_agent("router", rules)
|
||||
response = await agent.process("something else")
|
||||
assert response.content == "Generic router"
|
||||
assert response.confidence == ConfidenceLevel.MEDIUM
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_fallback_when_nothing_matches(self):
|
||||
rules = [
|
||||
{
|
||||
"match": {"agent_type": "error_analysis"},
|
||||
"response": {"content": "Error help", "confidence": "high", "agent_type": "error_analysis"},
|
||||
}
|
||||
]
|
||||
fallback = {"content": "No match found.", "confidence": "low", "agent_type": "unknown"}
|
||||
agent = self._make_agent("router", rules, fallback=fallback)
|
||||
response = await agent.process("anything")
|
||||
assert response.content == "No match found."
|
||||
assert response.confidence == ConfidenceLevel.LOW
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_static_backend_metadata_flag(self):
|
||||
"""Every response should have static_backend: True in metadata."""
|
||||
rules = [
|
||||
{
|
||||
"match": {"agent_type": "router"},
|
||||
"response": {"content": "Hello", "confidence": "high", "agent_type": "router"},
|
||||
}
|
||||
]
|
||||
agent = self._make_agent("router", rules)
|
||||
response = await agent.process("test")
|
||||
assert response.metadata.get("static_backend") is True
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_fallback_also_has_metadata_flag(self):
|
||||
agent = self._make_agent("router", [])
|
||||
response = await agent.process("test")
|
||||
assert response.metadata.get("static_backend") is True
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_defaults_applied(self):
|
||||
"""confidence from defaults block used when rule omits it."""
|
||||
rules = [
|
||||
{
|
||||
"match": {"agent_type": "router"},
|
||||
"response": {"content": "No confidence set", "agent_type": "router"},
|
||||
}
|
||||
]
|
||||
agent = self._make_agent("router", rules, defaults={"confidence": "medium"})
|
||||
response = await agent.process("test")
|
||||
assert response.confidence == ConfidenceLevel.MEDIUM
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_response_is_agent_response(self):
|
||||
rules = [
|
||||
{
|
||||
"match": {"agent_type": "router"},
|
||||
"response": {"content": "Hello", "confidence": "high", "agent_type": "router"},
|
||||
}
|
||||
]
|
||||
agent = self._make_agent("router", rules)
|
||||
response = await agent.process("test")
|
||||
assert isinstance(response, AgentResponse)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_first_matching_rule_wins(self):
|
||||
rules = [
|
||||
{
|
||||
"match": {"agent_type": "router"},
|
||||
"response": {"content": "First", "confidence": "high", "agent_type": "router"},
|
||||
},
|
||||
{
|
||||
"match": {"agent_type": "router"},
|
||||
"response": {"content": "Second", "confidence": "medium", "agent_type": "router"},
|
||||
},
|
||||
]
|
||||
agent = self._make_agent("router", rules)
|
||||
response = await agent.process("test")
|
||||
assert response.content == "First"
|
||||
|
||||
def test_static_agent_is_base_galaxy_agent(self):
|
||||
agent = self._make_agent("router", [])
|
||||
assert isinstance(agent, BaseGalaxyAgent)
|
||||
|
||||
def test_create_agent_raises(self):
|
||||
agent = self._make_agent("router", [])
|
||||
with pytest.raises(NotImplementedError):
|
||||
agent._create_agent()
|
||||
|
||||
def test_get_system_prompt_empty(self):
|
||||
agent = self._make_agent("router", [])
|
||||
assert agent.get_system_prompt() == ""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_context_rule_fails_when_no_context(self):
|
||||
"""Rule requiring context should NOT match when context is None."""
|
||||
rules = [
|
||||
{
|
||||
"match": {"agent_type": "router", "context": {"page_content": "methods"}},
|
||||
"response": {"content": "Context match", "confidence": "high", "agent_type": "router"},
|
||||
}
|
||||
]
|
||||
agent = self._make_agent("router", rules)
|
||||
response = await agent.process("test", context=None)
|
||||
assert response.content == "fallback"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_context_rule_matches_when_context_present(self):
|
||||
rules = [
|
||||
{
|
||||
"match": {"agent_type": "router", "context": {"page_content": "(?i)methods"}},
|
||||
"response": {"content": "Context match", "confidence": "high", "agent_type": "router"},
|
||||
}
|
||||
]
|
||||
agent = self._make_agent("router", rules)
|
||||
response = await agent.process("test", context={"page_content": "## Methods\nSome content"})
|
||||
assert response.content == "Context match"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_suggestions_preserved(self):
|
||||
rules = [
|
||||
{
|
||||
"match": {"agent_type": "router"},
|
||||
"response": {
|
||||
"content": "Hello",
|
||||
"confidence": "high",
|
||||
"agent_type": "router",
|
||||
"suggestions": [
|
||||
{
|
||||
"action_type": "save_tool",
|
||||
"description": "Save tool",
|
||||
"parameters": {"tool_id": "test", "tool_yaml": "test: true"},
|
||||
"confidence": "high",
|
||||
"priority": 1,
|
||||
}
|
||||
],
|
||||
},
|
||||
}
|
||||
]
|
||||
agent = self._make_agent("router", rules)
|
||||
response = await agent.process("test")
|
||||
assert len(response.suggestions) == 1
|
||||
assert response.suggestions[0].action_type.value == "save_tool"
|
||||
assert response.suggestions[0].parameters["tool_id"] == "test"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_reasoning_preserved(self):
|
||||
rules = [
|
||||
{
|
||||
"match": {"agent_type": "router"},
|
||||
"response": {
|
||||
"content": "Hello",
|
||||
"confidence": "high",
|
||||
"agent_type": "router",
|
||||
"reasoning": "Because reasons",
|
||||
},
|
||||
}
|
||||
]
|
||||
agent = self._make_agent("router", rules)
|
||||
response = await agent.process("test")
|
||||
assert response.reasoning == "Because reasons"
|
||||
|
||||
|
||||
# --- StaticAgentRegistry tests ---
|
||||
|
||||
|
||||
class TestStaticAgentRegistry:
|
||||
def test_list_agents(self, static_registry):
|
||||
agents = static_registry.list_agents()
|
||||
assert "router" in agents
|
||||
assert "error_analysis" in agents
|
||||
|
||||
def test_get_agent_returns_static_agent(self, static_registry):
|
||||
# deps not used by StaticAgent, pass None
|
||||
agent = static_registry.get_agent("router", None)
|
||||
assert isinstance(agent, StaticAgent)
|
||||
assert isinstance(agent, BaseGalaxyAgent)
|
||||
|
||||
def test_is_registered_known_type(self, static_registry):
|
||||
assert static_registry.is_registered("router") is True
|
||||
assert static_registry.is_registered("error_analysis") is True
|
||||
|
||||
def test_is_registered_unknown_with_fallback(self, static_registry):
|
||||
# Unknown type but fallback exists → registered
|
||||
assert static_registry.is_registered("nonexistent") is True
|
||||
|
||||
def test_is_registered_unknown_no_fallback(self, yaml_config_path):
|
||||
# Modify config to remove fallback
|
||||
with open(yaml_config_path) as f:
|
||||
config = yaml.safe_load(f)
|
||||
config.pop("fallback", None)
|
||||
with tempfile.NamedTemporaryFile(mode="w", suffix=".yml", delete=False) as f:
|
||||
yaml.dump(config, f)
|
||||
no_fallback_path = f.name
|
||||
try:
|
||||
registry = StaticAgentRegistry(no_fallback_path)
|
||||
assert registry.is_registered("nonexistent") is False
|
||||
finally:
|
||||
os.unlink(no_fallback_path)
|
||||
|
||||
def test_get_agent_info(self, static_registry):
|
||||
info = static_registry.get_agent_info("router")
|
||||
assert info["agent_type"] == "router"
|
||||
assert info["class_name"] == "StaticAgent"
|
||||
assert info["metadata"]["static_backend"] is True
|
||||
|
||||
def test_list_agent_info(self, static_registry):
|
||||
infos = static_registry.list_agent_info()
|
||||
assert len(infos) >= 2
|
||||
types = [i["agent_type"] for i in infos]
|
||||
assert "router" in types
|
||||
assert "error_analysis" in types
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_end_to_end_query(self, static_registry):
|
||||
"""Registry → get_agent → process → response."""
|
||||
agent = static_registry.get_agent("router", None)
|
||||
response = await agent.process("Hello!")
|
||||
assert "Hello" in response.content or "help" in response.content.lower()
|
||||
assert response.confidence == ConfidenceLevel.HIGH
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_end_to_end_fallback(self, static_registry):
|
||||
agent = static_registry.get_agent("unknown_type", None)
|
||||
response = await agent.process("xyzzy")
|
||||
assert "no matching rule" in response.content.lower()
|
||||
|
||||
def test_static_registry_is_agent_registry(self, static_registry):
|
||||
from galaxy.agents.registry import AgentRegistry
|
||||
|
||||
assert isinstance(static_registry, AgentRegistry)
|
||||
@@ -948,8 +948,8 @@ class TestExpectations(FunctionalTestToolTestCase):
|
||||
tests = tests_dict["tests"]
|
||||
assert len(tests) == 10
|
||||
test_0 = tests[0]
|
||||
assert len(test_0["stderr"]) == 1
|
||||
assert len(test_0["stdout"]) == 2
|
||||
assert len(test_0["stderr"]) == 2
|
||||
assert len(test_0["stdout"]) == 1
|
||||
|
||||
|
||||
class TestExpectationsCommandVersion(FunctionalTestToolTestCase):
|
||||
|
||||
Reference in New Issue
Block a user