From c37217956f9907537ad5f7fca1210a682c76dd9f Mon Sep 17 00:00:00 2001 From: Dannon Baker Date: Fri, 12 Jun 2026 11:21:11 -0400 Subject: [PATCH 1/7] Add failing test for router dropping interface context The router answers tool-usage questions directly instead of handing off, but its process() override sends the raw query to the model without running _prepare_prompt, so the active interface context (the tool the user is viewing) never reaches the model. Asking "how do I use this tool?" with Random Lines in context gets back "what tool?". This test pins the expected behavior and fails against the current code. --- test/unit/app/test_agents.py | 49 ++++++++++++++++++++++++++++++++++++ 1 file changed, 49 insertions(+) diff --git a/test/unit/app/test_agents.py b/test/unit/app/test_agents.py index 8d918632e89..8336d12199b 100644 --- a/test/unit/app/test_agents.py +++ b/test/unit/app/test_agents.py @@ -592,6 +592,55 @@ class TestAgentUnitMocked: assert kwargs["message_history"] is None assert args[0] == "Tell me more about the second one" + @pytest.mark.asyncio + async def test_router_injects_interface_context_into_prompt(self): + """The router must fold the active interface context (e.g. the tool the user is + viewing) into the prompt it sends to the model. Without this, "how do I use this + tool?" reaches the model with no referent and it answers "what tool?" even though + the UI shows the context. Specialists get this via _prepare_prompt; the router has + to do the same for the queries it answers directly rather than handing off.""" + router = QueryRouterAgent(self.deps) + + with mock.patch.object(router, "_run_with_retry") as mock_run: + mock_result = mock.Mock(spec=["output"]) + mock_result.output = "Here is how to use Random Lines." + mock_run.return_value = mock_result + + await router.process( + "how do I use this tool?", + context={ + "interface_context": { + "contextType": "tool", + "toolName": "Random Lines", + "toolId": "random_lines1", + } + }, + ) + + mock_run.assert_called_once() + prompt = mock_run.call_args[0][0] + assert "Random Lines" in prompt + assert "how do I use this tool?" in prompt + + @pytest.mark.asyncio + async def test_router_does_not_leak_routing_flags_into_prompt(self): + """Routing-only bookkeeping (responding_to_clarification) is for the router's own + logic, not the model -- it must never surface in the prompt text.""" + router = QueryRouterAgent(self.deps) + + with mock.patch.object(router, "_run_with_retry") as mock_run: + mock_result = mock.Mock(spec=["output"]) + mock_result.output = "Routed." + mock_run.return_value = mock_result + + await router.process( + "the second one", + context={"responding_to_clarification": True}, + ) + + prompt = mock_run.call_args[0][0] + assert "responding_to_clarification" not in prompt + @pytest.mark.asyncio async def test_router_asks_for_clarification(self): """When the model calls ask_for_clarification, the router surfaces it as a From 7266a641669845e3907166cd6557df2ffbf7ab42 Mon Sep 17 00:00:00 2001 From: Dannon Baker Date: Fri, 12 Jun 2026 11:22:11 -0400 Subject: [PATCH 2/7] Inject interface context into router's direct-answer prompt The router's process() override sent the raw query straight to the model, skipping the _prepare_prompt step every specialist runs via the base class. So when the router answers a question directly (which is exactly what it does for tool-usage help), the active interface context never reached the model -- ask "how do I use this tool?" with Random Lines in context and you'd get back "what tool?". Build the prompt the same way the base class does, stripping history since that rides the separate message_history channel. Also mark responding_to_clarification internal so that routing-only flag doesn't leak into the prompt text. --- lib/galaxy/agents/base.py | 2 +- lib/galaxy/agents/router.py | 6 +++++- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/lib/galaxy/agents/base.py b/lib/galaxy/agents/base.py index d69d02b6996..8bf50e3bd1d 100644 --- a/lib/galaxy/agents/base.py +++ b/lib/galaxy/agents/base.py @@ -372,7 +372,7 @@ class BaseGalaxyAgent(ABC): agent_type: str agent: Agent[GalaxyAgentDependencies, Any] - _INTERNAL_CONTEXT_KEYS = frozenset({"run_state"}) + _INTERNAL_CONTEXT_KEYS = frozenset({"run_state", "responding_to_clarification"}) # Fallback when no max_tokens is configured. 8k leaves headroom on every # backend we currently support (smallest is Qwen3-32B at 32k context). diff --git a/lib/galaxy/agents/router.py b/lib/galaxy/agents/router.py index 7f4756e08ab..b6c252e4482 100644 --- a/lib/galaxy/agents/router.py +++ b/lib/galaxy/agents/router.py @@ -546,7 +546,11 @@ class QueryRouterAgent(BaseGalaxyAgent): previous_handoff_context = self._handoff_context self._handoff_context = context.copy() if context else {} try: - result = await self._run_with_retry(query, message_history=message_history) + # Fold the active interface context (the tool/dataset/etc. the user is + # viewing) into the prompt for queries the router answers directly -- + # history rides the separate message_history channel, so strip it here. + prompt = self._prepare_prompt(query, self._strip_history_from_context(context or {})) + result = await self._run_with_retry(prompt, message_history=message_history) finally: self._handoff_context = previous_handoff_context content = extract_result_content(result) From 379b7810f55d4f675df0be415244ca0b13c42aab Mon Sep 17 00:00:00 2001 From: Dannon Baker Date: Fri, 12 Jun 2026 12:56:57 -0400 Subject: [PATCH 3/7] Tell the router how to use the active interface context Now that the interface context reaches the router's prompt, the system prompt should explain it. Add a short section telling the router to treat the [Active interface context: ...] line as the referent for "this tool" / "this dataset" style phrases, but to let the user's actual message decide the route -- "my job failed" on a tool form is still error analysis, not usage help. --- lib/galaxy/agents/prompts/router.md | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/lib/galaxy/agents/prompts/router.md b/lib/galaxy/agents/prompts/router.md index 8e46a28dce6..6a75c55a800 100644 --- a/lib/galaxy/agents/prompts/router.md +++ b/lib/galaxy/agents/prompts/router.md @@ -27,6 +27,12 @@ For off-topic questions (general coding, non-scientific topics, unrelated softwa Users can @mention specific datasets or histories in their messages. When entity references are present, they appear as structured context (e.g. "Referenced entities: Dataset #42 'Mapped reads' (bam, ok)"). Use this information to ground your answers -- refer to the specific dataset names, types, and states rather than asking the user to clarify which data they mean. +## Active Interface Context + +The UI tells you what the user is currently looking at via a leading "[Active interface context: ...]" line (e.g. the tool form they have open, a dataset, a workflow, or a job). Treat it as the referent for deictic phrases -- "this tool", "this dataset", "it", "here" -- so "how do I use this tool?" while viewing the Random Lines form is a usage question about Random Lines, not a reason to ask which tool they mean. + +It is context, not a command. The user's actual message still decides the route: "my job failed" while a tool form is open is still error analysis, not tool-usage help. When the message names its own subject explicitly, that wins over the interface context. + ## How to Respond You have access to specialist agents that you can route queries to. Choose the appropriate response: From e6bcb99949feaa4aee3b930781cba32d4ff8d5ef Mon Sep 17 00:00:00 2001 From: Dannon Baker Date: Wed, 17 Jun 2026 16:25:18 -0400 Subject: [PATCH 4/7] Add missing agent-type labels to the chat UI agentTypes.ts had no entries for the tool_recommendation, orchestrator, history, and workflow_report agent types, so getAgentLabel() fell through to rendering the raw backend string (e.g. "tool_recommendation") in the chat UI. Add the four mappings with FA6 icons, and drop the stale dataset_analyzer entry since there's no backend agent type by that name. --- client/src/components/GalaxyAI/agentTypes.ts | 15 +++++++++++++-- 1 file changed, 13 insertions(+), 2 deletions(-) diff --git a/client/src/components/GalaxyAI/agentTypes.ts b/client/src/components/GalaxyAI/agentTypes.ts index 81ec8ef9516..fa29b998691 100644 --- a/client/src/components/GalaxyAI/agentTypes.ts +++ b/client/src/components/GalaxyAI/agentTypes.ts @@ -2,13 +2,16 @@ import type { IconDefinition } from "@fortawesome/fontawesome-svg-core"; import { faBook, faBug, - faChartBar, + faFileContract, faGraduationCap, + faHistory, faMagic, faPlus, faQuestionCircle, faRobot, faRoute, + faSitemap, + faTools, } from "@fortawesome/free-solid-svg-icons"; import { AGENT_LABELS } from "@/components/Page/constants"; @@ -26,7 +29,15 @@ export const agentTypes: AgentType[] = [ { value: "router", label: "Router", icon: faRoute, description: "Query router" }, { value: "error_analysis", label: "Error Analysis", icon: faBug, description: "Debug tool errors" }, { value: "custom_tool", label: "Custom Tool", icon: faPlus, description: "Create custom tools" }, - { value: "dataset_analyzer", label: "Dataset Analyzer", icon: faChartBar, description: "Analyze datasets" }, + { + value: "tool_recommendation", + label: "Tool Recommendation", + icon: faTools, + description: "Recommend tools & workflows", + }, + { value: "orchestrator", label: "Orchestrator", icon: faSitemap, description: "Coordinates multiple steps" }, + { value: "history", label: "History", icon: faHistory, description: "Summarize your analysis" }, + { value: "workflow_report", label: "Workflow Report", icon: faFileContract, description: "Workflow run reports" }, { value: "gtn_training", label: "GTN Training", icon: faGraduationCap, description: "Find tutorials" }, { value: "clarification", label: "Clarification", icon: faQuestionCircle, description: "Needs more info" }, { From 875243c49c3fd66d864981d727db02213d89571a Mon Sep 17 00:00:00 2001 From: Dannon Baker Date: Wed, 17 Jun 2026 16:30:08 -0400 Subject: [PATCH 5/7] Persist and display the agent that actually answered The chat endpoint stored the request's agent_type ("auto") at the top level of each turn, so reopening a conversation badged every router-handoff turn "Auto (Router)" instead of the specialist that really handled it. The real responder is already in agent_response, so persist its agent_type, and on reload prefer the nested agent_response over the stored top-level type -- which also fixes rows already written the old way. Same fallback applied client-side in GalaxyAI.vue. --- client/src/components/GalaxyAI.vue | 2 +- lib/galaxy/webapps/galaxy/api/chat.py | 26 +++++++++-- test/unit/app/test_chat_manager.py | 67 +++++++++++++++++++++++++++ 3 files changed, 89 insertions(+), 6 deletions(-) diff --git a/client/src/components/GalaxyAI.vue b/client/src/components/GalaxyAI.vue index c5df4dc7dc9..011fcd09a98 100644 --- a/client/src/components/GalaxyAI.vue +++ b/client/src/components/GalaxyAI.vue @@ -413,7 +413,7 @@ async function fetchConversation(exchangeId: string) { }; if (msg.role === "assistant") { - message.agentType = msg.agent_type; + message.agentType = msg.agent_response?.agent_type || msg.agent_type; message.confidence = msg.agent_response?.confidence || "medium"; message.feedback = msg.feedback === 1 ? "up" : msg.feedback === 0 ? "down" : null; diff --git a/lib/galaxy/webapps/galaxy/api/chat.py b/lib/galaxy/webapps/galaxy/api/chat.py index f28bcf78461..57209a8ec7f 100644 --- a/lib/galaxy/webapps/galaxy/api/chat.py +++ b/lib/galaxy/webapps/galaxy/api/chat.py @@ -106,6 +106,16 @@ JobIdPathParam = Annotated[ ] +def _responder_agent_type(data: dict[str, Any]) -> str: + """Agent type that actually answered a stored turn. + + Prefer the nested agent_response (the real responder, e.g. the specialist after a + router handoff) over the top-level agent_type, which records the *request* type + ("auto"). Older rows only stored the request type at the top level. + """ + return (data.get("agent_response") or {}).get("agent_type") or data.get("agent_type", "unknown") + + @router.cbv class ChatAPI: """Chat interface for AI agents. @@ -265,7 +275,7 @@ class ChatAPI: conversation_data = { "query": query_text, "response": result.get("response", ""), - "agent_type": agent_type, + "agent_type": agent_resp.agent_type if agent_resp else agent_type, "agent_response": agent_resp.model_dump() if agent_resp else None, } message_content = json.dumps(conversation_data) @@ -280,7 +290,7 @@ class ChatAPI: "agent_response": agent_resp.model_dump() if agent_resp else None, } exchange = self.chat_manager.create_page_chat( - trans, page_id, query_text, storable_result, agent_type + trans, page_id, query_text, storable_result, agent_resp.agent_type if agent_resp else agent_type ) result["exchange_id"] = exchange.id else: @@ -290,7 +300,13 @@ class ChatAPI: "agent_response": agent_resp.model_dump() if agent_resp else None, } exchange = await anyio.to_thread.run_sync( - partial(self.chat_manager.create_general_chat, trans, query_text, storable_result, agent_type) + partial( + self.chat_manager.create_general_chat, + trans, + query_text, + storable_result, + agent_resp.agent_type if agent_resp else agent_type, + ) ) result["exchange_id"] = exchange.id @@ -489,7 +505,7 @@ class ChatAPI: { "role": "assistant", "content": data["response"], - "agent_type": data.get("agent_type", "unknown"), + "agent_type": _responder_agent_type(data), "agent_response": data.get("agent_response"), "timestamp": msg.create_time.isoformat() if msg.create_time else None, "feedback": msg.feedback, @@ -524,7 +540,7 @@ class ChatAPI: id=exchange.id, query=data.get("query", ""), response=data.get("response", ""), - agent_type=data.get("agent_type", "unknown"), + agent_type=_responder_agent_type(data), agent_response=agent_response, timestamp=message.create_time.isoformat() if message.create_time else None, feedback=message.feedback, diff --git a/test/unit/app/test_chat_manager.py b/test/unit/app/test_chat_manager.py index ee26bb78e21..d280f83d59c 100644 --- a/test/unit/app/test_chat_manager.py +++ b/test/unit/app/test_chat_manager.py @@ -6,6 +6,10 @@ from unittest import mock import pytest from galaxy.managers.chat import ChatManager +from galaxy.webapps.galaxy.api.chat import ( + _responder_agent_type, + ChatAPI, +) def _make_trans(user_id=1): @@ -270,3 +274,66 @@ class TestResolvePageFromInterfaceContext: assert page_id == 42 assert page_obj is fake_page mock_get.assert_called_once_with(trans, 42) + + +class TestResponderAgentType: + """The displayed agent_type should be the agent that actually answered (the nested + agent_response), not the request type stored at the top level ("auto").""" + + def test_prefers_nested_response_agent_type(self): + data = {"agent_type": "auto", "agent_response": {"agent_type": "error_analysis"}} + assert _responder_agent_type(data) == "error_analysis" + + def test_falls_back_to_top_level_when_no_response(self): + data = {"agent_type": "gtn_training", "agent_response": None} + assert _responder_agent_type(data) == "gtn_training" + + def test_unknown_when_nothing_present(self): + assert _responder_agent_type({}) == "unknown" + + +class TestGetExchangeMessagesAttribution: + """Reopening a conversation should badge each turn with the real responder, so a + router handoff turn (persisted with top-level agent_type "auto") reloads as the + specialist that actually answered.""" + + @staticmethod + def _api_for(message): + api = ChatAPI.__new__(ChatAPI) + msg = mock.Mock() + msg.message = message + msg.feedback = None + msg.create_time = None + exchange = _FakeChatExchange() + exchange.messages = [msg] + api.chat_manager = mock.Mock() + api.chat_manager.get_exchange_by_id.return_value = exchange + return api + + def _assistant_turn(self, message): + api = self._api_for(message) + messages = api.get_exchange_messages(exchange_id=1, trans=_make_trans(), user=mock.Mock()) + assistant = [m for m in messages if m["role"] == "assistant"] + assert len(assistant) == 1 + return assistant[0] + + def test_handoff_turn_reloads_as_specialist(self): + message = json.dumps( + { + "query": "why did my job fail?", + "response": "Your tool hit an out-of-memory error.", + "agent_type": "auto", + "agent_response": {"agent_type": "error_analysis"}, + } + ) + assert self._assistant_turn(message)["agent_type"] == "error_analysis" + + def test_falls_back_to_stored_type_without_response(self): + message = json.dumps( + { + "query": "find me a tutorial", + "response": "Here are some tutorials.", + "agent_type": "gtn_training", + } + ) + assert self._assistant_turn(message)["agent_type"] == "gtn_training" From 0146e85d2daa22aa4a15f10a9adf8a5e4ebe4692 Mon Sep 17 00:00:00 2001 From: Dannon Baker Date: Thu, 18 Jun 2026 19:43:09 -0400 Subject: [PATCH 6/7] Cap the tool recommendation agent's tool calls so it stops looping The tool recommendation agent never bounded its tool calls, so on a wordy query it would keep re-searching tools and workflows until it tripped pydantic-ai's default request_limit of 50 and the whole turn errored out -- I hit this about one in three times asking it to recommend an IWC workflow. Give it the same soft budget the GTN agent already uses: after 8 data-gathering calls each search tool hands back a "stop searching, answer now" message instead of more data, so the model recommends from what it already found. A stubborn model still loops a little but recovers and returns the right workflow now instead of crashing. --- lib/galaxy/agents/tools.py | 46 ++++++++++++++++++++++++++++++++++++ test/unit/app/test_agents.py | 15 ++++++++++++ 2 files changed, 61 insertions(+) diff --git a/lib/galaxy/agents/tools.py b/lib/galaxy/agents/tools.py index 1539dfc71a3..7bc760264b0 100644 --- a/lib/galaxy/agents/tools.py +++ b/lib/galaxy/agents/tools.py @@ -71,6 +71,37 @@ class ToolRecommendationAgent(BaseGalaxyAgent): agent_type = AgentType.TOOL_RECOMMENDATION + # The model can keep re-searching tools and workflows long after it has + # enough to recommend from. Left unbounded it eventually trips pydantic-ai's + # default request_limit (50) and the whole turn errors out. Cap the number of + # data-gathering tool calls and, once spent, hand back a terminal instruction + # so the model produces its recommendation from what it already found. + MAX_TOOL_CALLS = 8 + # Name the search tools to avoid rather than saying "no tools" -- the + # structured recommendation is itself delivered via an output tool call, so a + # blanket "no tools" instruction makes the model emit prose that fails + # structured-output validation. + _TOOL_BUDGET_MESSAGE = ( + "SEARCH BUDGET REACHED. You already have enough information to recommend. " + "Do NOT call search_galaxy_tools, get_galaxy_tool_details, " + "get_galaxy_tool_categories, search_iwc_workflows, or " + "get_iwc_workflow_details again. Produce your final structured " + "recommendation now from the tools and workflows already found above. If " + "nothing is a strong match, say so and recommend the closest option." + ) + + def __init__(self, deps: GalaxyAgentDependencies): + super().__init__(deps) + self._tool_calls = 0 + + def _charge_tool_budget(self) -> Optional[str]: + """Count a data-gathering tool call; once over budget return a stop + message instead of more data so the model recommends from what it has.""" + self._tool_calls += 1 + if self._tool_calls > self.MAX_TOOL_CALLS: + return self._TOOL_BUDGET_MESSAGE + return None + def _create_agent(self) -> Agent[GalaxyAgentDependencies, Any]: if self._supports_structured_output(): agent = Agent( @@ -95,6 +126,9 @@ class ToolRecommendationAgent(BaseGalaxyAgent): Use this to find real tool IDs for tools you want to recommend. Returns tool id, name, description, and category for matching tools. """ + over_budget = self._charge_tool_budget() + if over_budget: + return over_budget results = await self.search_tools(query) if not results: return f"No tools found matching '{query}'" @@ -113,6 +147,9 @@ class ToolRecommendationAgent(BaseGalaxyAgent): Use this after searching to get more details about a tool you want to recommend, including input/output formats, version, and requirements. """ + over_budget = self._charge_tool_budget() + if over_budget: + return over_budget details = await self.get_tool_details(tool_id) if "error" in details: return f"Error: {details['error']}" @@ -139,6 +176,9 @@ class ToolRecommendationAgent(BaseGalaxyAgent): Use this to understand what kinds of tools are available before searching. """ + over_budget = self._charge_tool_budget() + if over_budget: + return over_budget categories = await self.get_tool_categories() if not categories: return "No tool categories found" @@ -153,6 +193,9 @@ class ToolRecommendationAgent(BaseGalaxyAgent): single tool. Returns ranked workflow entries with trsID, name, description, step count, and the tools each workflow uses. """ + over_budget = self._charge_tool_budget() + if over_budget: + return over_budget results = await self.search_iwc_workflows(query, limit=limit) if not results: return f"No IWC workflows found matching '{query}'" @@ -172,6 +215,9 @@ class ToolRecommendationAgent(BaseGalaxyAgent): Use after search_iwc_workflows to get the complete tool list, authors, categories, and readme summary before recommending. """ + over_budget = self._charge_tool_budget() + if over_budget: + return over_budget details = await self.get_iwc_workflow_details(trs_id) if details is None: return f"No IWC workflow found with trsID {trs_id}" diff --git a/test/unit/app/test_agents.py b/test/unit/app/test_agents.py index 8336d12199b..a57d00c08f9 100644 --- a/test/unit/app/test_agents.py +++ b/test/unit/app/test_agents.py @@ -1341,6 +1341,21 @@ class TestAgentUnitMocked: assert suggestion.parameters["name"] == "RNA-seq" assert suggestion.priority == 1 # promoted when no tool comes back + def test_tool_rec_tool_budget_caps_then_returns_stop_message(self): + # Past MAX_TOOL_CALLS the budget hands back a terminal "stop searching" + # message instead of more data, so the model answers from what it already + # found rather than looping until pydantic-ai's request_limit trips and + # the whole turn errors out. + agent = ToolRecommendationAgent.__new__(ToolRecommendationAgent) + agent._tool_calls = 0 + + allowed = [agent._charge_tool_budget() for _ in range(agent.MAX_TOOL_CALLS)] + assert allowed == [None] * agent.MAX_TOOL_CALLS + + over_budget = agent._charge_tool_budget() + assert over_budget is not None + assert "SEARCH BUDGET REACHED" in over_budget + def test_tool_rec_workflow_suggestion_demoted_when_tool_present(self): agent = self._make_tool_rec_agent() # Stub _verify_tool_exists so the tool path produces a TOOL_RUN. From 52d815392531d8df6297b7dec2588b93073056c0 Mon Sep 17 00:00:00 2001 From: mvdbeek Date: Fri, 19 Jun 2026 11:30:37 +0200 Subject: [PATCH 7/7] Move chat agent-type attribution to ChatManager The agent-type attribution logic (_responder_agent_type and the exchange-message formatting) lived in galaxy.webapps.galaxy.api.chat, so the new unit tests in test/unit/app/test_chat_manager.py imported it from the web_apps layer. That test runs as part of the galaxy-app package suite, which does not (and should not) depend on web_apps, so the import pulled in a2wsgi and failed with ModuleNotFoundError. Push the pure logic down to ChatManager.responder_agent_type and ChatManager.get_exchange_messages, alongside the existing _message_is_clarification / get_routing_history helpers that already parse stored exchange messages. The API endpoint and history formatter now delegate to the manager, and the tests import only app-layer code. --- lib/galaxy/managers/chat.py | 58 +++++++++++++++++++++++++++ lib/galaxy/webapps/galaxy/api/chat.py | 55 +------------------------ test/unit/app/test_chat_manager.py | 23 +++++------ 3 files changed, 69 insertions(+), 67 deletions(-) diff --git a/lib/galaxy/managers/chat.py b/lib/galaxy/managers/chat.py index 29d622ce568..e41f34d8c11 100644 --- a/lib/galaxy/managers/chat.py +++ b/lib/galaxy/managers/chat.py @@ -347,6 +347,64 @@ class ChatManager: pydantic_messages.append(ModelResponse(parts=[TextPart(content=msg.message)])) return pydantic_messages + @staticmethod + def responder_agent_type(data: dict[str, Any]) -> str: + """Agent type that actually answered a stored turn. + + Prefer the nested agent_response (the real responder, e.g. the specialist after a + router handoff) over the top-level agent_type, which records the *request* type + ("auto"). Older rows only stored the request type at the top level. + """ + return (data.get("agent_response") or {}).get("agent_type") or data.get("agent_type", "unknown") + + def get_exchange_messages(self, trans: ProvidesUserContext, exchange_id: int) -> list[dict[str, Any]]: + """Return all messages for an exchange as user/assistant dicts. + + Each stored message holds one query/response turn as JSON; the assistant turn is + badged with the agent that actually responded (see ``responder_agent_type``). + """ + exchange = self.get_exchange_by_id(trans, exchange_id) + if not exchange: + return [] + + messages: list[dict[str, Any]] = [] + for msg in exchange.messages: + try: + # Parse JSON content to extract individual messages + data = json.loads(msg.message) + # Add both user query and assistant response + if "query" in data: + messages.append( + { + "role": "user", + "content": data["query"], + "timestamp": msg.create_time.isoformat() if msg.create_time else None, + } + ) + if "response" in data: + messages.append( + { + "role": "assistant", + "content": data["response"], + "agent_type": self.responder_agent_type(data), + "agent_response": data.get("agent_response"), + "timestamp": msg.create_time.isoformat() if msg.create_time else None, + "feedback": msg.feedback, + } + ) + except (json.JSONDecodeError, AttributeError): + # Fallback for non-JSON messages + messages.append( + { + "role": "assistant", + "content": msg.message, + "timestamp": msg.create_time.isoformat() if msg.create_time else None, + "feedback": msg.feedback, + } + ) + + return messages + @staticmethod def _message_is_clarification(message) -> bool: """Whether a stored message's response was a clarifying question (by ``agent_type``).""" diff --git a/lib/galaxy/webapps/galaxy/api/chat.py b/lib/galaxy/webapps/galaxy/api/chat.py index 57209a8ec7f..bc257eaf51a 100644 --- a/lib/galaxy/webapps/galaxy/api/chat.py +++ b/lib/galaxy/webapps/galaxy/api/chat.py @@ -106,16 +106,6 @@ JobIdPathParam = Annotated[ ] -def _responder_agent_type(data: dict[str, Any]) -> str: - """Agent type that actually answered a stored turn. - - Prefer the nested agent_response (the real responder, e.g. the specialist after a - router handoff) over the top-level agent_type, which records the *request* type - ("auto"). Older rows only stored the request type at the top level. - """ - return (data.get("agent_response") or {}).get("agent_type") or data.get("agent_type", "unknown") - - @router.cbv class ChatAPI: """Chat interface for AI agents. @@ -481,48 +471,7 @@ class ChatAPI: user: User = DependsOnUser, ) -> list[dict[str, Any]]: """Get all messages for a specific chat exchange.""" - exchange = self.chat_manager.get_exchange_by_id(trans, exchange_id) - if not exchange: - return [] - - messages = [] - - for msg in exchange.messages: - try: - # Parse JSON content to extract individual messages - data = json.loads(msg.message) - # Add both user query and assistant response - if "query" in data: - messages.append( - { - "role": "user", - "content": data["query"], - "timestamp": msg.create_time.isoformat() if msg.create_time else None, - } - ) - if "response" in data: - messages.append( - { - "role": "assistant", - "content": data["response"], - "agent_type": _responder_agent_type(data), - "agent_response": data.get("agent_response"), - "timestamp": msg.create_time.isoformat() if msg.create_time else None, - "feedback": msg.feedback, - } - ) - except (json.JSONDecodeError, AttributeError): - # Fallback for non-JSON messages - messages.append( - { - "role": "assistant", - "content": msg.message, - "timestamp": msg.create_time.isoformat() if msg.create_time else None, - "feedback": msg.feedback, - } - ) - - return messages + return self.chat_manager.get_exchange_messages(trans, exchange_id) def _format_exchange_history(self, exchanges) -> list[ChatHistoryItemResponse]: """Convert a list of ChatExchange ORM objects into API response models.""" @@ -540,7 +489,7 @@ class ChatAPI: id=exchange.id, query=data.get("query", ""), response=data.get("response", ""), - agent_type=_responder_agent_type(data), + agent_type=self.chat_manager.responder_agent_type(data), agent_response=agent_response, timestamp=message.create_time.isoformat() if message.create_time else None, feedback=message.feedback, diff --git a/test/unit/app/test_chat_manager.py b/test/unit/app/test_chat_manager.py index d280f83d59c..036d46927bb 100644 --- a/test/unit/app/test_chat_manager.py +++ b/test/unit/app/test_chat_manager.py @@ -6,10 +6,6 @@ from unittest import mock import pytest from galaxy.managers.chat import ChatManager -from galaxy.webapps.galaxy.api.chat import ( - _responder_agent_type, - ChatAPI, -) def _make_trans(user_id=1): @@ -282,14 +278,14 @@ class TestResponderAgentType: def test_prefers_nested_response_agent_type(self): data = {"agent_type": "auto", "agent_response": {"agent_type": "error_analysis"}} - assert _responder_agent_type(data) == "error_analysis" + assert ChatManager.responder_agent_type(data) == "error_analysis" def test_falls_back_to_top_level_when_no_response(self): data = {"agent_type": "gtn_training", "agent_response": None} - assert _responder_agent_type(data) == "gtn_training" + assert ChatManager.responder_agent_type(data) == "gtn_training" def test_unknown_when_nothing_present(self): - assert _responder_agent_type({}) == "unknown" + assert ChatManager.responder_agent_type({}) == "unknown" class TestGetExchangeMessagesAttribution: @@ -298,21 +294,20 @@ class TestGetExchangeMessagesAttribution: specialist that actually answered.""" @staticmethod - def _api_for(message): - api = ChatAPI.__new__(ChatAPI) + def _exchange_for(message): msg = mock.Mock() msg.message = message msg.feedback = None msg.create_time = None exchange = _FakeChatExchange() exchange.messages = [msg] - api.chat_manager = mock.Mock() - api.chat_manager.get_exchange_by_id.return_value = exchange - return api + return exchange def _assistant_turn(self, message): - api = self._api_for(message) - messages = api.get_exchange_messages(exchange_id=1, trans=_make_trans(), user=mock.Mock()) + mgr = ChatManager() + exchange = self._exchange_for(message) + with mock.patch.object(mgr, "get_exchange_by_id", return_value=exchange): + messages = mgr.get_exchange_messages(_make_trans(), exchange_id=1) assistant = [m for m in messages if m["role"] == "assistant"] assert len(assistant) == 1 return assistant[0]