From e61bbf7e29cc48d54dfab1cabca006428173140e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Hugo=20Dettner=20K=C3=A4llander?= Date: Mon, 23 Feb 2026 15:30:01 +0100 Subject: [PATCH 01/18] Fix BIA hit extraction and improve fallback result payload --- src/pages/AgentPage.tsx | 523 +++++++++++++++++++++++++++++++++++++++- 1 file changed, 522 insertions(+), 1 deletion(-) diff --git a/src/pages/AgentPage.tsx b/src/pages/AgentPage.tsx index b3e51379..81ada277 100644 --- a/src/pages/AgentPage.tsx +++ b/src/pages/AgentPage.tsx @@ -66,6 +66,8 @@ const MAX_CHAT_PROXY_APP_ID_LENGTH = 63; const CHAT_PROXY_REQUEST_TIMEOUT_MS = 300_000; const CHAT_PROXY_RESOLVE_TIMEOUT_MS = 15_000; const CHAT_PROXY_COMPLETION_TIMEOUT_MS = 300_000; +const AGENT_TOOL_EXECUTION_LIMIT = 50; +const AGENT_ITERATION_SOFT_TIMEOUT_MS = 90_000; const slugifyBranchName = (branchName: string): string => { const normalized = branchName @@ -1011,6 +1013,8 @@ import json import traceback import js import asyncio +from urllib.parse import urlparse +import httpx from hypha_rpc import connect_to_server try: @@ -1031,6 +1035,146 @@ def _is_timeout_payload(payload): return isinstance(err, str) and ("timed out" in err.lower() or "timeout" in err.lower()) return False +def _extract_timeout_seconds(timeout_value, fallback=30.0): + if timeout_value is None: + return float(fallback) + if isinstance(timeout_value, (int, float)): + return float(timeout_value) + candidate = getattr(timeout_value, "read", None) + if isinstance(candidate, (int, float)): + return float(candidate) + candidate = getattr(timeout_value, "connect", None) + if isinstance(candidate, (int, float)): + return float(candidate) + return float(fallback) + +def _should_proxy_request_url(url): + try: + parsed = urlparse(str(url)) + except Exception: + return False + host = (parsed.hostname or "").lower() + return host == "beta.bioimagearchive.org" + +class _ProxyHTTPXResponse: + def __init__(self, payload, url): + self._payload = payload or {} + self.status_code = int(self._payload.get("status_code") or self._payload.get("status") or 500) + self.headers = self._payload.get("headers") or {} + self.url = self._payload.get("url") or str(url) + self._json = self._payload.get("json") + self._text = self._payload.get("text") + + @property + def text(self): + if self._text is not None: + return self._text + if self._json is not None: + try: + return json.dumps(self._json, ensure_ascii=False) + except Exception: + return str(self._json) + return "" + + def json(self): + if self._json is not None: + return self._json + if self._text is None: + return {} + return json.loads(self._text) + + def raise_for_status(self): + if self.status_code >= 400: + detail = self._payload.get("error") + if isinstance(detail, str) and detail: + raise RuntimeError(f"Server error '{self.status_code}' for url '{self.url}': {detail}") + raise RuntimeError(f"Server error '{self.status_code}' for url '{self.url}'") + +async def _resolve_proxy_service_for_utilities(): + service_ids = json.loads('${chatProxyServiceIdsLiteral}') + server = await connect_to_server({ + "server_url": "https://hypha.aicell.io", + "method_timeout": 600 + }) + + proxy = None + resolved_service_id = None + last_service_error = None + for service_id in service_ids: + try: + proxy = await server.get_service(service_id, {"timeout": 600}) + resolved_service_id = service_id + break + except BaseException as service_exp: + last_service_error = service_exp + + if proxy is None: + if last_service_error: + raise last_service_error + raise RuntimeError("No chat-proxy service IDs configured") + + print(f"DEBUG: Utility proxy resolved via {resolved_service_id}") + return proxy + +async def _proxy_request_url_via_service(url, method="GET", headers=None, timeout=30.0): + try: + proxy = await _resolve_proxy_service_for_utilities() + if not hasattr(proxy, 'request_url'): + return { + "ok": False, + "error": "chat-proxy service is missing request_url; deploy updated chat-proxy app", + "status_code": 502, + "url": str(url), + } + payload = await asyncio.wait_for( + proxy.request_url(url=url, method=method, headers=headers or {}, timeout=float(timeout)), + timeout=max(5.0, float(timeout) + 5.0) + ) + return payload if isinstance(payload, dict) else { + "ok": False, + "error": f"Unexpected request_url payload type: {type(payload)}", + "status_code": 500, + "url": str(url), + "text": str(payload), + } + except Exception as exp: + return { + "ok": False, + "error": str(exp), + "status_code": 500, + "url": str(url), + } + +_original_asyncclient_request = globals().get('__original_asyncclient_request') + +async def _patched_asyncclient_request(self, method, url, *args, **kwargs): + if _should_proxy_request_url(url): + timeout_seconds = _extract_timeout_seconds(kwargs.get("timeout"), 30.0) + headers = kwargs.get("headers") or {} + payload = await _proxy_request_url_via_service( + str(url), + method=str(method).upper(), + headers=headers, + timeout=timeout_seconds, + ) + return _ProxyHTTPXResponse(payload, str(url)) + + return await _original_asyncclient_request(self, method, url, *args, **kwargs) + +def _install_httpx_proxy_patch(): + global _original_asyncclient_request + if _original_asyncclient_request is None: + _original_asyncclient_request = httpx.AsyncClient.request + globals()['__original_asyncclient_request'] = _original_asyncclient_request + + already_patched = getattr(httpx.AsyncClient.request, '__name__', '') == '_patched_asyncclient_request' + if already_patched: + print("DEBUG: httpx CORS proxy patch already installed") + return + + httpx.AsyncClient.request = _patched_asyncclient_request + print("DEBUG: Installed httpx proxy patch for beta.bioimagearchive.org") + async def _python_fallback_chat_completion(messages, tools, tool_choice, model): last_exception = None service_ids = json.loads('${chatProxyServiceIdsLiteral}') @@ -1125,6 +1269,7 @@ async def hypha_chat_proxy(messages_json, tools_json, tool_choice_json, model): return json.dumps({"error": f"bridge-error: {str(e)}"}) print("DEBUG: hypha_chat_proxy bridge ready") +_install_httpx_proxy_patch() `; await executeCode(installCode); } @@ -1670,6 +1815,11 @@ print("DEBUG: hypha_chat_proxy bridge ready") role: 'system', content: 'When tools/functions are available, prefer calling them to retrieve concrete results. Do not claim inability if a relevant tool exists.' }); + + history.unshift({ + role: 'system', + content: `Stop iterating tools after about ${Math.floor(AGENT_ITERATION_SOFT_TIMEOUT_MS / 1000)} seconds and present the best results gathered so far. If a single-term fallback search was used, explicitly mention that in the response.` + }); // Add the new message history.push({ role: newMessage.role, content: newMessage.content }); @@ -1876,10 +2026,286 @@ async def _chat_wrapper(): tool_calls = response_message.get('tool_calls') content = response_message.get('content') - max_turns = 4 + max_turns = ${AGENT_TOOL_EXECUTION_LIMIT} + soft_timeout_ms = ${AGENT_ITERATION_SOFT_TIMEOUT_MS} + soft_deadline = asyncio.get_event_loop().time() + (soft_timeout_ms / 1000.0) turns = 0 tool_result_cache = {} service_unavailable_tool_errors = 0 + upstream_server_tool_errors = 0 + empty_search_tool_results = 0 + saw_nonempty_search_result = False + single_term_fallback_used = False + single_term_fallback_terms = [] + total_tool_calls = 0 + search_snapshots = [] + + def _safe_parse_hits_payload(payload): + hits_value = payload.get("hits", []) if isinstance(payload, dict) else [] + if isinstance(hits_value, dict): + hits_list = hits_value.get("hits", []) + if not isinstance(hits_list, list): + hits_list = [] + total_obj = hits_value.get("total", {}) if isinstance(hits_value.get("total"), dict) else {} + total_value = total_obj.get("value") if isinstance(total_obj, dict) else None + if not isinstance(total_value, int): + total_value = len(hits_list) + return hits_list, total_value + + if isinstance(hits_value, list): + return hits_value, len(hits_value) + + return [], 0 + + def _extract_relaxed_terms(query_text): + text = str(query_text or "") + for ch in ['(', ')', '[', ']', '{', '}', '"', "'", ',', ';', ':']: + text = text.replace(ch, ' ') + terms = [] + seen = set() + for part in text.split(): + lowered = part.strip().lower() + if not lowered or lowered in ('and', 'or', 'not'): + continue + if len(lowered) <= 2: + continue + if lowered in seen: + continue + seen.add(lowered) + terms.append(lowered) + return terms[:4] + + def _collect_search_snapshot(function_name, response_payload): + if function_name not in ('search_datasets', 'search_images'): + return + if not isinstance(response_payload, dict): + return + total_value = response_payload.get('total') + results_value = response_payload.get('results') + if not isinstance(total_value, int) or not isinstance(results_value, list): + return + search_snapshots.append({ + 'kind': function_name, + 'query': str(response_payload.get('query') or ''), + 'total': total_value, + 'results': results_value, + 'single_term_fallback_used': bool(response_payload.get('single_term_fallback_used')), + 'single_term_fallback_terms': response_payload.get('single_term_fallback_terms') if isinstance(response_payload.get('single_term_fallback_terms'), list) else [], + }) + + def _format_search_summary(): + if not search_snapshots: + return None + ranked = sorted(search_snapshots, key=lambda item: int(item.get('total') or 0), reverse=True) + best = ranked[0] + best_results = best.get('results') or [] + if not isinstance(best_results, list) or not best_results: + return None + + best_kind = best.get('kind') + label = 'datasets' if best_kind == 'search_datasets' else 'images' + lines = [f"I found {min(5, len(best_results))} {label} for query: {best.get('query')}"] + for idx, item in enumerate(best_results[:5], start=1): + if not isinstance(item, dict): + continue + title = item.get('title') or item.get('name') or item.get('id') or item.get('accession') or 'Untitled' + accession = item.get('accession') or '' + url = item.get('url') or '' + if url: + lines.append(f"{idx}. {title} ({url})") + elif accession: + lines.append(f"{idx}. {title} (accession: {accession})") + else: + lines.append(f"{idx}. {title}") + + fallback_terms = best.get('single_term_fallback_terms') if isinstance(best.get('single_term_fallback_terms'), list) else [] + if best.get('single_term_fallback_used'): + if fallback_terms: + lines.append(f"Note: single-term fallback search was used with terms: {', '.join([str(term) for term in fallback_terms])}") + else: + lines.append("Note: single-term fallback search was used.") + return "\\n".join(lines) + + async def _fetch_archive_hits(base_url, query_text): + encoded_query = quote(str(query_text), safe='"()[]{}:*?+-/\\\\') + fetch_url = f"{base_url}?query={encoded_query}" + async with httpx.AsyncClient(timeout=30.0) as client: + response = await client.get(fetch_url) + response.raise_for_status() + payload = response.json() + hits, total = _safe_parse_hits_payload(payload) + return fetch_url, hits, total + + def _first_nonempty_string(values): + for value in values: + if isinstance(value, str): + text = value.strip() + if text: + return text + return None + + def _get_additional_metadata_value(source_payload, metadata_name): + if not isinstance(source_payload, dict): + return None + metadata_entries = source_payload.get('additional_metadata') + if not isinstance(metadata_entries, list): + return None + for entry in metadata_entries: + if not isinstance(entry, dict): + continue + if entry.get('name') != metadata_name: + continue + value_payload = entry.get('value') + if isinstance(value_payload, dict): + return value_payload + return None + + def _extract_dataset_result(item): + source_payload = item.get('_source') if isinstance(item, dict) else None + if not isinstance(source_payload, dict): + source_payload = item if isinstance(item, dict) else {} + + accession = _first_nonempty_string([ + source_payload.get('accession_id'), + source_payload.get('accession'), + source_payload.get('id'), + item.get('_id') if isinstance(item, dict) else None, + ]) + title = _first_nonempty_string([ + source_payload.get('title'), + source_payload.get('name'), + source_payload.get('dataset'), + accession, + source_payload.get('uuid'), + item.get('_id') if isinstance(item, dict) else None, + ]) or 'Untitled' + + return { + "title": title, + "accession": accession or "", + "url": f"https://beta.bioimagearchive.org/bioimage-archive/study/{accession}" if accession else None, + "uuid": source_payload.get('uuid') if isinstance(source_payload.get('uuid'), str) else None, + "description": source_payload.get('description') if isinstance(source_payload.get('description'), str) else None, + "doi": source_payload.get('doi') if isinstance(source_payload.get('doi'), str) else None, + "release_date": source_payload.get('release_date') if isinstance(source_payload.get('release_date'), str) else None, + "score": item.get('_score') if isinstance(item, dict) else None, + } + + def _extract_image_result(item): + source_payload = item.get('_source') if isinstance(item, dict) else None + if not isinstance(source_payload, dict): + source_payload = item if isinstance(item, dict) else {} + + file_pattern_payload = _get_additional_metadata_value(source_payload, 'file_pattern') + file_pattern = file_pattern_payload.get('file_pattern') if isinstance(file_pattern_payload, dict) else None + + creation_process = source_payload.get('creation_process') if isinstance(source_payload.get('creation_process'), dict) else {} + acquisition_process = creation_process.get('acquisition_process') if isinstance(creation_process.get('acquisition_process'), list) else [] + first_acquisition = acquisition_process[0] if acquisition_process and isinstance(acquisition_process[0], dict) else {} + acquisition_title = first_acquisition.get('title') if isinstance(first_acquisition.get('title'), str) else None + + accession = _first_nonempty_string([ + source_payload.get('accession_id'), + source_payload.get('accession'), + source_payload.get('study_accession'), + ]) + image_id = _first_nonempty_string([ + source_payload.get('uuid'), + item.get('_id') if isinstance(item, dict) else None, + ]) or "" + + title = _first_nonempty_string([ + source_payload.get('title'), + source_payload.get('name'), + source_payload.get('label'), + file_pattern if isinstance(file_pattern, str) else None, + acquisition_title, + image_id, + ]) or 'Untitled' + + return { + "id": image_id, + "accession": accession or "", + "title": title, + "study_url": f"https://beta.bioimagearchive.org/bioimage-archive/study/{accession}" if accession else None, + "dataset_uuid": source_payload.get('submission_dataset_uuid') if isinstance(source_payload.get('submission_dataset_uuid'), str) else None, + "file_pattern": file_pattern if isinstance(file_pattern, str) else None, + "acquisition_title": acquisition_title, + "score": item.get('_score') if isinstance(item, dict) else None, + } + + async def _fallback_archive_search(function_name, function_args): + query = function_args.get("query", "") if isinstance(function_args, dict) else "" + limit_raw = function_args.get("limit", 10) if isinstance(function_args, dict) else 10 + try: + limit_value = int(limit_raw) + except Exception: + limit_value = 10 + limit_value = max(1, limit_value) + + is_image_search = function_name == 'search_images' + base_url = "https://beta.bioimagearchive.org/search/search/fts/image" if is_image_search else "https://beta.bioimagearchive.org/search/search/fts" + url, hits, total = await _fetch_archive_hits(base_url, str(query)) + fallback_used = False + fallback_terms = [] + + if total == 0: + relaxed_terms = _extract_relaxed_terms(query) + if len(relaxed_terms) >= 2: + merged_hits = [] + seen_keys = set() + for relaxed_term in relaxed_terms: + _, candidate_hits, _ = await _fetch_archive_hits(base_url, relaxed_term) + for item in candidate_hits: + key = ( + item.get('accession') + or item.get('id') + or item.get('_id') + or json.dumps(item, sort_keys=True, ensure_ascii=False) + ) + if key in seen_keys: + continue + seen_keys.add(key) + merged_hits.append(item) + if len(merged_hits) >= limit_value: + break + if len(merged_hits) >= limit_value: + break + if merged_hits: + hits = merged_hits + total = len(merged_hits) + fallback_used = True + fallback_terms = relaxed_terms + url = f"{base_url}?query={quote(' '.join(relaxed_terms), safe='"()[]{}:*?+-/\\\\')}" + top_hits = [] + + if is_image_search: + for item in hits[:limit_value]: + top_hits.append(_extract_image_result(item)) + else: + for item in hits[:limit_value]: + top_hits.append(_extract_dataset_result(item)) + + return { + "query": str(query), + "url": url, + "total": total, + "results": top_hits, + "single_term_fallback_used": fallback_used, + "single_term_fallback_terms": fallback_terms, + } + + def _is_archive_service_error(error_text, status_code): + if not isinstance(error_text, str): + return False + lowered = error_text.lower() + if 'beta.bioimagearchive.org' not in lowered: + return False + return ( + f"server error '{status_code}'" in lowered + or f"http {status_code}" in lowered + or f"status code {status_code}" in lowered + ) while True: tool_calls = response_message.get('tool_calls') @@ -1896,6 +2322,7 @@ async def _chat_wrapper(): messages.append(response_message) for tool_call in tool_calls: + total_tool_calls += 1 function_name = tool_call['function']['name'] args_content = tool_call['function']['arguments'] @@ -1931,8 +2358,27 @@ async def _chat_wrapper(): tool_result_cache[cache_key] = function_response function_response_text = str(function_response) + _collect_search_snapshot(function_name, function_response) if function_name in ('search_datasets', 'search_images') and '503 Service Unavailable' in function_response_text: service_unavailable_tool_errors += 1 + if function_name in ('search_datasets', 'search_images') and ( + _is_archive_service_error(function_response_text, 500) + or _is_archive_service_error(function_response_text, 502) + or _is_archive_service_error(function_response_text, 504) + ): + upstream_server_tool_errors += 1 + if function_name in ('search_datasets', 'search_images') and isinstance(function_response, dict): + total_value = function_response.get('total') + if isinstance(total_value, int): + if total_value == 0: + empty_search_tool_results += 1 + else: + saw_nonempty_search_result = True + if function_response.get('single_term_fallback_used'): + single_term_fallback_used = True + terms = function_response.get('single_term_fallback_terms') + if isinstance(terms, list): + single_term_fallback_terms.extend([str(term) for term in terms if str(term)]) messages.append({ "tool_call_id": tool_call_id, @@ -1942,8 +2388,40 @@ async def _chat_wrapper(): }) except Exception as e: error_text = str(e) + recovered_with_fallback = False + if function_name in ('search_datasets', 'search_images') and 'slice(None,' in error_text: + try: + fallback_response = await _fallback_archive_search(function_name, function_args) + fallback_response_text = str(fallback_response) + tool_result_cache[cache_key] = fallback_response + _collect_search_snapshot(function_name, fallback_response) + messages.append({ + "tool_call_id": tool_call_id, + "role": "tool", + "name": function_name, + "content": fallback_response_text, + }) + recovered_with_fallback = True + print(f"Recovered {function_name} via fallback parser after slice error") + except Exception as fallback_exp: + error_text = f"{error_text}; fallback failed: {fallback_exp}" + + if recovered_with_fallback: + if isinstance(fallback_response, dict) and fallback_response.get('single_term_fallback_used'): + single_term_fallback_used = True + terms = fallback_response.get('single_term_fallback_terms') + if isinstance(terms, list): + single_term_fallback_terms.extend([str(term) for term in terms if str(term)]) + continue + if function_name in ('search_datasets', 'search_images') and '503 Service Unavailable' in error_text: service_unavailable_tool_errors += 1 + if function_name in ('search_datasets', 'search_images') and ( + _is_archive_service_error(error_text, 500) + or _is_archive_service_error(error_text, 502) + or _is_archive_service_error(error_text, 504) + ): + upstream_server_tool_errors += 1 messages.append({ "tool_call_id": tool_call_id, "role": "tool", @@ -1964,6 +2442,39 @@ async def _chat_wrapper(): }) return + if upstream_server_tool_errors >= 2: + send_response({ + "text": "The archive search API is currently returning upstream errors (HTTP 500/502/504), so I can’t retrieve reliable results right now. Please try again shortly." + }) + return + + if empty_search_tool_results >= 3 and not saw_nonempty_search_result: + send_response({ + "text": "I ran multiple archive searches but found no matching datasets/images for this query right now. Please try a broader or different query." + }) + return + + if saw_nonempty_search_result and total_tool_calls >= 1: + summary_text = _format_search_summary() + if isinstance(summary_text, str) and summary_text: + send_response({"text": summary_text}) + return + + if asyncio.get_event_loop().time() >= soft_deadline: + summary_text = _format_search_summary() + timeout_note = f"I stopped tool iterations after about {int(soft_timeout_ms / 1000)} seconds and returned the best results gathered so far." + if single_term_fallback_used: + unique_terms = sorted({term for term in single_term_fallback_terms if term}) + if unique_terms: + timeout_note = f"{timeout_note} Single-term fallback was used ({', '.join(unique_terms)})." + else: + timeout_note = f"{timeout_note} Single-term fallback was used." + if isinstance(summary_text, str) and summary_text: + send_response({"text": f"{summary_text}\\n\\n{timeout_note}"}) + else: + send_response({"text": timeout_note}) + return + turns += 1 next_tools_json = json.dumps(tools) if tools else None next_tool_choice_json = json.dumps("auto") if tools else None @@ -1976,10 +2487,20 @@ async def _chat_wrapper(): try: next_result = json.loads(next_result_json) except Exception as parse_err: + if saw_nonempty_search_result: + summary_text = _format_search_summary() + if isinstance(summary_text, str) and summary_text: + send_response({"text": f"{summary_text}\\n\\nI’m returning the best results gathered so far because a follow-up model response could not be parsed ({parse_err})."}) + return send_response({"text": f"Error from proxy: Invalid JSON response ({parse_err})"}) return if isinstance(next_result, dict) and "error" in next_result: + if saw_nonempty_search_result: + summary_text = _format_search_summary() + if isinstance(summary_text, str) and summary_text: + send_response({"text": f"{summary_text}\\n\\nI’m returning the best results gathered so far because the follow-up model call failed: {next_result['error']}"}) + return send_response({"text": f"Error from proxy: {next_result['error']}"}) return From 9809f3e0c330e14fc2f683102dc1105c7d189b1d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Hugo=20Dettner=20K=C3=A4llander?= Date: Mon, 23 Feb 2026 15:46:53 +0100 Subject: [PATCH 02/18] Fix BioImage Finder startup script hit extraction --- docs/bioimage-finder-startup-script.py | 168 ++++++++++++++++++++----- 1 file changed, 140 insertions(+), 28 deletions(-) diff --git a/docs/bioimage-finder-startup-script.py b/docs/bioimage-finder-startup-script.py index 2c8a8f64..cd080daa 100644 --- a/docs/bioimage-finder-startup-script.py +++ b/docs/bioimage-finder-startup-script.py @@ -19,6 +19,138 @@ def _build_url(base_url: str, query: str) -> str: return f"{base_url}?query={encoded}" +def _extract_hits_and_total(payload: Dict[str, Any] | Any) -> tuple[List[Dict[str, Any]], int]: + if not isinstance(payload, dict): + return [], 0 + + hits_value = payload.get("hits", []) + if isinstance(hits_value, list): + return hits_value, len(hits_value) + + if isinstance(hits_value, dict): + hits_list = hits_value.get("hits", []) + if not isinstance(hits_list, list): + hits_list = [] + total_obj = hits_value.get("total", {}) if isinstance(hits_value.get("total"), dict) else {} + total = total_obj.get("value") if isinstance(total_obj, dict) else None + if not isinstance(total, int): + total = len(hits_list) + return hits_list, total + + return [], 0 + + +def _first_nonempty_string(values: List[Any]) -> str | None: + for value in values: + if isinstance(value, str): + cleaned = value.strip() + if cleaned: + return cleaned + return None + + +def _metadata_value(source_payload: Dict[str, Any], metadata_name: str) -> Dict[str, Any] | None: + metadata_entries = source_payload.get("additional_metadata") + if not isinstance(metadata_entries, list): + return None + for entry in metadata_entries: + if not isinstance(entry, dict): + continue + if entry.get("name") != metadata_name: + continue + value_payload = entry.get("value") + if isinstance(value_payload, dict): + return value_payload + return None + + +def _dataset_result_from_hit(item: Dict[str, Any]) -> Dict[str, Any]: + source_payload = item.get("_source") if isinstance(item.get("_source"), dict) else item + + accession = _first_nonempty_string( + [ + source_payload.get("accession_id"), + source_payload.get("accession"), + source_payload.get("id"), + item.get("_id"), + ] + ) + title = _first_nonempty_string( + [ + source_payload.get("title"), + source_payload.get("name"), + source_payload.get("dataset"), + accession, + source_payload.get("uuid"), + item.get("_id"), + ] + ) or "Untitled" + + return { + "title": title, + "accession": accession or "", + "url": ( + f"https://beta.bioimagearchive.org/bioimage-archive/study/{accession}" + if accession + else None + ), + "uuid": source_payload.get("uuid") if isinstance(source_payload.get("uuid"), str) else None, + "description": source_payload.get("description") if isinstance(source_payload.get("description"), str) else None, + "doi": source_payload.get("doi") if isinstance(source_payload.get("doi"), str) else None, + "release_date": source_payload.get("release_date") if isinstance(source_payload.get("release_date"), str) else None, + "score": item.get("_score"), + } + + +def _image_result_from_hit(item: Dict[str, Any]) -> Dict[str, Any]: + source_payload = item.get("_source") if isinstance(item.get("_source"), dict) else item + file_pattern_payload = _metadata_value(source_payload, "file_pattern") + file_pattern = file_pattern_payload.get("file_pattern") if isinstance(file_pattern_payload, dict) else None + + creation_process = source_payload.get("creation_process") if isinstance(source_payload.get("creation_process"), dict) else {} + acquisition_process = creation_process.get("acquisition_process") if isinstance(creation_process.get("acquisition_process"), list) else [] + first_acquisition = acquisition_process[0] if acquisition_process and isinstance(acquisition_process[0], dict) else {} + acquisition_title = first_acquisition.get("title") if isinstance(first_acquisition.get("title"), str) else None + + accession = _first_nonempty_string( + [ + source_payload.get("accession_id"), + source_payload.get("accession"), + source_payload.get("study_accession"), + ] + ) + image_id = _first_nonempty_string([source_payload.get("uuid"), item.get("_id")]) or "" + title = _first_nonempty_string( + [ + source_payload.get("title"), + source_payload.get("name"), + source_payload.get("label"), + file_pattern if isinstance(file_pattern, str) else None, + acquisition_title, + image_id, + ] + ) or "Untitled" + + return { + "id": image_id, + "accession": accession or "", + "title": title, + "study_url": ( + f"https://beta.bioimagearchive.org/bioimage-archive/study/{accession}" + if accession + else None + ), + "dataset_uuid": ( + source_payload.get("submission_dataset_uuid") + if isinstance(source_payload.get("submission_dataset_uuid"), str) + else None + ), + "file_pattern": file_pattern if isinstance(file_pattern, str) else None, + "acquisition_title": acquisition_title, + "score": item.get("_score"), + } + + async def _search_via_proxy(kind: str, query: str, limit: int) -> Dict[str, Any] | None: if js is None: return None @@ -71,30 +203,17 @@ async def search_datasets(query: str, limit: int = 10) -> Dict[str, Any]: response.raise_for_status() payload = response.json() - hits = payload.get("hits", []) if isinstance(payload, dict) else [] + hits, total = _extract_hits_and_total(payload) top_hits: List[Dict[str, Any]] = [] for item in hits[: max(1, limit)]: - title = ( - item.get("title") or item.get("name") or item.get("accession") or "Untitled" - ) - accession = item.get("accession") or item.get("id") or "" - top_hits.append( - { - "title": title, - "accession": accession, - "url": ( - f"https://www.ebi.ac.uk/bioimage-archive/{accession}" - if accession - else None - ), - } - ) + if isinstance(item, dict): + top_hits.append(_dataset_result_from_hit(item)) return { "query": query, "url": url, - "total": len(hits), + "total": total, "results": top_hits, } @@ -122,24 +241,17 @@ async def search_images(query: str, limit: int = 10) -> Dict[str, Any]: response.raise_for_status() payload = response.json() - hits = payload.get("hits", []) if isinstance(payload, dict) else [] + hits, total = _extract_hits_and_total(payload) top_hits: List[Dict[str, Any]] = [] for item in hits[: max(1, limit)]: - image_id = item.get("id") or item.get("_id") or "" - accession = item.get("accession") or item.get("study_accession") or "" - top_hits.append( - { - "id": image_id, - "accession": accession, - "title": item.get("title") or item.get("name") or image_id, - } - ) + if isinstance(item, dict): + top_hits.append(_image_result_from_hit(item)) return { "query": query, "url": url, - "total": len(hits), + "total": total, "results": top_hits, } From 13ca08e3111fff0a082e1c489d3f0db51547323e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Hugo=20Dettner=20K=C3=A4llander?= Date: Mon, 23 Feb 2026 18:11:51 +0100 Subject: [PATCH 03/18] Mark recovered fallback results as successful tool hits --- src/pages/AgentPage.tsx | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/src/pages/AgentPage.tsx b/src/pages/AgentPage.tsx index 81ada277..582ab7d7 100644 --- a/src/pages/AgentPage.tsx +++ b/src/pages/AgentPage.tsx @@ -2407,6 +2407,13 @@ async def _chat_wrapper(): error_text = f"{error_text}; fallback failed: {fallback_exp}" if recovered_with_fallback: + if function_name in ('search_datasets', 'search_images') and isinstance(fallback_response, dict): + total_value = fallback_response.get('total') + if isinstance(total_value, int): + if total_value == 0: + empty_search_tool_results += 1 + else: + saw_nonempty_search_result = True if isinstance(fallback_response, dict) and fallback_response.get('single_term_fallback_used'): single_term_fallback_used = True terms = fallback_response.get('single_term_fallback_terms') From 7dee949aecd9bb593618bc14ff622da1ecf8c481 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Hugo=20Dettner=20K=C3=A4llander?= Date: Mon, 23 Feb 2026 18:22:44 +0100 Subject: [PATCH 04/18] Make tool loop agent-agnostic and add startup artifact updater --- chat-proxy-app/app.py | 115 ++++++ .../bioimage_finder_startup_script.py | 295 +++++++++++++ scripts/update_agent_startup_script.py | 100 +++++ src/pages/AgentPage.tsx | 390 ++---------------- 4 files changed, 539 insertions(+), 361 deletions(-) create mode 100644 scripts/agent_startup_scripts/bioimage_finder_startup_script.py create mode 100644 scripts/update_agent_startup_script.py diff --git a/chat-proxy-app/app.py b/chat-proxy-app/app.py index 520628f1..ba90b53c 100644 --- a/chat-proxy-app/app.py +++ b/chat-proxy-app/app.py @@ -1,8 +1,11 @@ +import asyncio import json import logging import os from typing import Any +from urllib.parse import quote +import httpx from hypha_rpc import api from openai import AsyncOpenAI @@ -11,6 +14,116 @@ logger.setLevel(logging.INFO) _client: AsyncOpenAI | None = None +BIOSTUDIES_SEARCH_URL = "https://www.ebi.ac.uk/biostudies/api/v1/BioImages/search" + + +def _build_biostudies_url(query: str, limit: int) -> str: + encoded = quote(query, safe='"()[]{}:*?+-/') + bounded_limit = max(1, int(limit)) + return f"{BIOSTUDIES_SEARCH_URL}?query={encoded}&page=1&pageSize={bounded_limit}" + + +async def _fetch_json_with_retries( + url: str, + *, + attempts: int = 3, + retry_delay_seconds: float = 1.0, +) -> dict[str, Any]: + last_error: Exception | None = None + headers = { + "Accept": "application/json", + "User-Agent": "ri-scale-model-hub-chat-proxy/1.0", + } + for attempt in range(1, attempts + 1): + try: + async with httpx.AsyncClient(timeout=30.0, follow_redirects=True) as client: + response = await client.get(url, headers=headers) + response.raise_for_status() + payload = response.json() + if isinstance(payload, dict): + return payload + raise RuntimeError("Archive response was not a JSON object") + except Exception as exp: + last_error = exp + logger.warning( + "Archive fetch failed for %s (attempt %s/%s): %s", + url, + attempt, + attempts, + exp, + ) + if attempt < attempts: + await asyncio.sleep(retry_delay_seconds * attempt) + + raise RuntimeError( + f"BioImage Archive request failed after {attempts} attempts: {last_error}" + ) + + +async def search_datasets( + query: str, + limit: int = 10, + context: dict[str, Any] | None = None, +) -> dict[str, Any]: + url = _build_biostudies_url(query, limit) + payload = await _fetch_json_with_retries(url) + hits = payload.get("hits", []) if isinstance(payload, dict) else [] + top_hits: list[dict[str, Any]] = [] + + for item in hits[: max(1, int(limit))]: + title = ( + item.get("title") or item.get("name") or item.get("accession") or "Untitled" + ) + accession = item.get("accession") or item.get("id") or "" + top_hits.append( + { + "title": title, + "accession": accession, + "url": ( + f"https://www.ebi.ac.uk/bioimage-archive/{accession}" + if accession + else None + ), + } + ) + + return { + "query": query, + "url": url, + "total": payload.get("totalHits", len(hits)), + "results": top_hits, + "source": "biostudies", + } + + +async def search_images( + query: str, + limit: int = 10, + context: dict[str, Any] | None = None, +) -> dict[str, Any]: + url = _build_biostudies_url(query, limit) + payload = await _fetch_json_with_retries(url) + hits = payload.get("hits", []) if isinstance(payload, dict) else [] + top_hits: list[dict[str, Any]] = [] + + for item in hits[: max(1, int(limit))]: + image_id = item.get("id") or item.get("accession") or "" + accession = item.get("accession") or "" + top_hits.append( + { + "id": image_id, + "accession": accession, + "title": item.get("title") or item.get("name") or accession or image_id, + } + ) + + return { + "query": query, + "url": url, + "total": payload.get("totalHits", len(hits)), + "results": top_hits, + "source": "biostudies", + } async def _resolve_openai_key() -> str | None: @@ -89,5 +202,7 @@ async def chat_completion( "config": {"visibility": "public"}, "setup": setup, "chat_completion": chat_completion, + "search_datasets": search_datasets, + "search_images": search_images, } ) diff --git a/scripts/agent_startup_scripts/bioimage_finder_startup_script.py b/scripts/agent_startup_scripts/bioimage_finder_startup_script.py new file mode 100644 index 00000000..15a619d7 --- /dev/null +++ b/scripts/agent_startup_scripts/bioimage_finder_startup_script.py @@ -0,0 +1,295 @@ +import json +from typing import Any, Dict, List +from urllib.parse import quote + +import httpx + +try: + import js # type: ignore +except Exception: + js = None + + +BASE_SEARCH_URL = "https://beta.bioimagearchive.org/search/search/fts" +BASE_IMAGE_SEARCH_URL = "https://beta.bioimagearchive.org/search/search/fts/image" + + +def _build_url(base_url: str, query: str) -> str: + encoded = quote(query, safe='"()[]{}:*?+-/\\') + return f"{base_url}?query={encoded}" + + +def _extract_hits_and_total(payload: Dict[str, Any] | Any) -> tuple[List[Dict[str, Any]], int]: + if not isinstance(payload, dict): + return [], 0 + + hits_value = payload.get("hits", []) + if isinstance(hits_value, list): + return hits_value, len(hits_value) + + if isinstance(hits_value, dict): + hits_list = hits_value.get("hits", []) + if not isinstance(hits_list, list): + hits_list = [] + total_obj = hits_value.get("total", {}) if isinstance(hits_value.get("total"), dict) else {} + total = total_obj.get("value") if isinstance(total_obj, dict) else None + if not isinstance(total, int): + total = len(hits_list) + return hits_list, total + + return [], 0 + + +def _first_nonempty_string(values: List[Any]) -> str | None: + for value in values: + if isinstance(value, str): + cleaned = value.strip() + if cleaned: + return cleaned + return None + + +def _metadata_value(source_payload: Dict[str, Any], metadata_name: str) -> Dict[str, Any] | None: + metadata_entries = source_payload.get("additional_metadata") + if not isinstance(metadata_entries, list): + return None + for entry in metadata_entries: + if not isinstance(entry, dict): + continue + if entry.get("name") != metadata_name: + continue + value_payload = entry.get("value") + if isinstance(value_payload, dict): + return value_payload + return None + + +def _dataset_result_from_hit(item: Dict[str, Any]) -> Dict[str, Any]: + source_payload = item.get("_source") if isinstance(item.get("_source"), dict) else item + + accession = _first_nonempty_string( + [ + source_payload.get("accession_id"), + source_payload.get("accession"), + source_payload.get("id"), + item.get("_id"), + ] + ) + title = _first_nonempty_string( + [ + source_payload.get("title"), + source_payload.get("name"), + source_payload.get("dataset"), + accession, + source_payload.get("uuid"), + item.get("_id"), + ] + ) or "Untitled" + + return { + "title": title, + "accession": accession or "", + "url": ( + f"https://beta.bioimagearchive.org/bioimage-archive/study/{accession}" + if accession + else None + ), + "uuid": source_payload.get("uuid") if isinstance(source_payload.get("uuid"), str) else None, + "description": source_payload.get("description") if isinstance(source_payload.get("description"), str) else None, + "doi": source_payload.get("doi") if isinstance(source_payload.get("doi"), str) else None, + "release_date": source_payload.get("release_date") if isinstance(source_payload.get("release_date"), str) else None, + "score": item.get("_score"), + } + + +def _image_result_from_hit(item: Dict[str, Any]) -> Dict[str, Any]: + source_payload = item.get("_source") if isinstance(item.get("_source"), dict) else item + file_pattern_payload = _metadata_value(source_payload, "file_pattern") + file_pattern = file_pattern_payload.get("file_pattern") if isinstance(file_pattern_payload, dict) else None + + creation_process = source_payload.get("creation_process") if isinstance(source_payload.get("creation_process"), dict) else {} + acquisition_process = creation_process.get("acquisition_process") if isinstance(creation_process.get("acquisition_process"), list) else [] + first_acquisition = acquisition_process[0] if acquisition_process and isinstance(acquisition_process[0], dict) else {} + acquisition_title = first_acquisition.get("title") if isinstance(first_acquisition.get("title"), str) else None + + accession = _first_nonempty_string( + [ + source_payload.get("accession_id"), + source_payload.get("accession"), + source_payload.get("study_accession"), + ] + ) + image_id = _first_nonempty_string([source_payload.get("uuid"), item.get("_id")]) or "" + title = _first_nonempty_string( + [ + source_payload.get("title"), + source_payload.get("name"), + source_payload.get("label"), + file_pattern if isinstance(file_pattern, str) else None, + acquisition_title, + image_id, + ] + ) or "Untitled" + + return { + "id": image_id, + "accession": accession or "", + "title": title, + "study_url": ( + f"https://beta.bioimagearchive.org/bioimage-archive/study/{accession}" + if accession + else None + ), + "dataset_uuid": ( + source_payload.get("submission_dataset_uuid") + if isinstance(source_payload.get("submission_dataset_uuid"), str) + else None + ), + "file_pattern": file_pattern if isinstance(file_pattern, str) else None, + "acquisition_title": acquisition_title, + "score": item.get("_score"), + } + + +async def _search_via_proxy(kind: str, query: str, limit: int) -> Dict[str, Any] | None: + if js is None: + return None + try: + bridge = getattr(js.globalThis, "bioimage_archive_search", None) + except Exception: + return None + + if not bridge: + return None + + try: + result = await bridge(kind, query, int(limit)) + if hasattr(result, "to_py"): + result = result.to_py() + if isinstance(result, str): + parsed = json.loads(result) + if isinstance(parsed, dict): + return parsed + return {"error": "Proxy returned non-dict response"} + if isinstance(result, dict): + return result + return {"error": "Proxy returned unsupported response type"} + except Exception as exp: + return {"error": f"Proxy search failed: {exp}"} + + +async def search_datasets(query: str, limit: int = 10) -> Dict[str, Any]: + """ + Search BioImage Archive datasets by full-text query. + + Args: + query: User search text, supports boolean operators (AND/OR/NOT), quotes, wildcards. + limit: Maximum number of hits to return in the summarized output. + + Returns: + Dictionary with request URL, total count, and top results. + """ + proxied = await _search_via_proxy("datasets", query, limit) + if isinstance(proxied, dict): + if "error" in proxied: + raise RuntimeError(proxied["error"]) + return proxied + + url = _build_url(BASE_SEARCH_URL, query) + async with httpx.AsyncClient(timeout=30.0) as client: + response = await client.get(url) + response.raise_for_status() + payload = response.json() + + hits, total = _extract_hits_and_total(payload) + top_hits: List[Dict[str, Any]] = [] + + for item in hits[: max(1, limit)]: + if isinstance(item, dict): + top_hits.append(_dataset_result_from_hit(item)) + + return { + "query": query, + "url": url, + "total": total, + "results": top_hits, + } + + +async def search_images(query: str, limit: int = 10) -> Dict[str, Any]: + """ + Search BioImage Archive images endpoint by full-text query. + + Args: + query: User search text for image-level index. + limit: Maximum number of hits to return in the summarized output. + + Returns: + Dictionary with request URL, total count, and top results. + """ + proxied = await _search_via_proxy("images", query, limit) + if isinstance(proxied, dict): + if "error" in proxied: + raise RuntimeError(proxied["error"]) + return proxied + + url = _build_url(BASE_IMAGE_SEARCH_URL, query) + async with httpx.AsyncClient(timeout=30.0) as client: + response = await client.get(url) + response.raise_for_status() + payload = response.json() + + hits, total = _extract_hits_and_total(payload) + top_hits: List[Dict[str, Any]] = [] + + for item in hits[: max(1, limit)]: + if isinstance(item, dict): + top_hits.append(_image_result_from_hit(item)) + + return { + "query": query, + "url": url, + "total": total, + "results": top_hits, + } + + +def explain_advanced_query_syntax() -> str: + """ + Return a concise guide for advanced BioImage Archive query syntax. + + Returns: + Human-readable syntax instructions with examples. + """ + return ( + "Advanced search syntax:\n" + "- Words are case-insensitive.\n" + "- Default behavior is OR across terms.\n" + "- Use AND / OR / NOT and parentheses for boolean logic.\n" + "- Use quoted phrases for exact matching.\n" + "- Wildcards: * for any sequence, ? for a single character.\n" + "Examples:\n" + "1) confocal fluorescence microscopy\n" + "2) confocal AND fluorescence AND microscopy\n" + '3) "confocal fluorescence microscopy"\n' + "4) microscopy AND (fluorescence OR confocal)\n" + "5) microscopy AND NOT (fluorescence OR confocal)\n" + ) + + +print( + """ +You are the RI-SCALE BioImage Finder. + +You can call these utility functions directly: +- search_datasets(query: str, limit: int = 10) +- search_images(query: str, limit: int = 10) +- explain_advanced_query_syntax() + +Use tools first whenever a user asks for archive results. +- When querying based on the users' prompt you should generally make a very brief search query string. +- Use the search query format " OR [...]", before any other queries. +Then provide a concise human summary with links/accessions. +If one query returns no results, try one or two query rewrites before concluding. +""" +) diff --git a/scripts/update_agent_startup_script.py b/scripts/update_agent_startup_script.py new file mode 100644 index 00000000..22e06336 --- /dev/null +++ b/scripts/update_agent_startup_script.py @@ -0,0 +1,100 @@ +import argparse +import asyncio +from pathlib import Path +from typing import Any, Dict + +from hypha_rpc import connect_to_server + + +def _extract_manifest(payload: Dict[str, Any]) -> Dict[str, Any]: + manifest = payload.get("manifest") + if isinstance(manifest, dict): + return manifest + return dict(payload) + + +async def _run(args: argparse.Namespace) -> None: + token = args.token + if not token: + raise ValueError("A token is required. Pass --token or set HYPHA_TOKEN in the environment.") + + startup_script_path = Path(args.startup_script).resolve() + if not startup_script_path.exists(): + raise FileNotFoundError(f"Startup script file not found: {startup_script_path}") + + startup_script = startup_script_path.read_text(encoding="utf-8") + + server = await connect_to_server( + { + "name": "startup-script-updater", + "server_url": args.server_url, + "token": token, + } + ) + artifact_manager = await server.get_service("public/artifact-manager") + + artifact = await artifact_manager.read(args.artifact_id) + manifest = _extract_manifest(artifact) + manifest["startup_script"] = startup_script + + await artifact_manager.edit( + artifact_id=args.artifact_id, + manifest=manifest, + stage=True, + ) + await artifact_manager.commit(args.artifact_id) + + updated = await artifact_manager.read(args.artifact_id) + updated_manifest = _extract_manifest(updated) + persisted_script = updated_manifest.get("startup_script") + + if not isinstance(persisted_script, str): + raise RuntimeError("Updated startup_script is missing or not a string.") + + if args.verify_contains and args.verify_contains not in persisted_script: + raise RuntimeError( + f"Verification failed: expected substring not found: {args.verify_contains!r}" + ) + + if persisted_script != startup_script: + raise RuntimeError("Verification failed: persisted startup_script content does not match source file.") + + print("Startup script updated and verified.") + print(f"artifact_id={args.artifact_id}") + print(f"startup_script_path={startup_script_path}") + + +async def main() -> None: + parser = argparse.ArgumentParser(description="Update startup_script for a Hypha agent artifact") + parser.add_argument( + "--artifact-id", + default="hypha-agents/grammatical-deduction-bury-enormously", + help="Full artifact id (workspace/alias)", + ) + parser.add_argument( + "--startup-script", + default="scripts/agent_startup_scripts/bioimage_finder_startup_script.py", + help="Path to local startup script file", + ) + parser.add_argument( + "--server-url", + default="https://hypha.aicell.io", + help="Hypha server URL", + ) + parser.add_argument( + "--token", + default=None, + help="Hypha token. Prefer passing via env and shell substitution.", + ) + parser.add_argument( + "--verify-contains", + default="_dataset_result_from_hit", + help="Optional substring that must be present in persisted startup_script", + ) + + args = parser.parse_args() + await _run(args) + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/src/pages/AgentPage.tsx b/src/pages/AgentPage.tsx index 582ab7d7..1510ac3b 100644 --- a/src/pages/AgentPage.tsx +++ b/src/pages/AgentPage.tsx @@ -2031,281 +2031,30 @@ async def _chat_wrapper(): soft_deadline = asyncio.get_event_loop().time() + (soft_timeout_ms / 1000.0) turns = 0 tool_result_cache = {} - service_unavailable_tool_errors = 0 - upstream_server_tool_errors = 0 - empty_search_tool_results = 0 - saw_nonempty_search_result = False - single_term_fallback_used = False - single_term_fallback_terms = [] total_tool_calls = 0 - search_snapshots = [] - - def _safe_parse_hits_payload(payload): - hits_value = payload.get("hits", []) if isinstance(payload, dict) else [] - if isinstance(hits_value, dict): - hits_list = hits_value.get("hits", []) - if not isinstance(hits_list, list): - hits_list = [] - total_obj = hits_value.get("total", {}) if isinstance(hits_value.get("total"), dict) else {} - total_value = total_obj.get("value") if isinstance(total_obj, dict) else None - if not isinstance(total_value, int): - total_value = len(hits_list) - return hits_list, total_value - - if isinstance(hits_value, list): - return hits_value, len(hits_value) - - return [], 0 - - def _extract_relaxed_terms(query_text): - text = str(query_text or "") - for ch in ['(', ')', '[', ']', '{', '}', '"', "'", ',', ';', ':']: - text = text.replace(ch, ' ') - terms = [] - seen = set() - for part in text.split(): - lowered = part.strip().lower() - if not lowered or lowered in ('and', 'or', 'not'): - continue - if len(lowered) <= 2: - continue - if lowered in seen: - continue - seen.add(lowered) - terms.append(lowered) - return terms[:4] - - def _collect_search_snapshot(function_name, response_payload): - if function_name not in ('search_datasets', 'search_images'): - return - if not isinstance(response_payload, dict): - return - total_value = response_payload.get('total') - results_value = response_payload.get('results') - if not isinstance(total_value, int) or not isinstance(results_value, list): - return - search_snapshots.append({ - 'kind': function_name, - 'query': str(response_payload.get('query') or ''), - 'total': total_value, - 'results': results_value, - 'single_term_fallback_used': bool(response_payload.get('single_term_fallback_used')), - 'single_term_fallback_terms': response_payload.get('single_term_fallback_terms') if isinstance(response_payload.get('single_term_fallback_terms'), list) else [], - }) - - def _format_search_summary(): - if not search_snapshots: - return None - ranked = sorted(search_snapshots, key=lambda item: int(item.get('total') or 0), reverse=True) - best = ranked[0] - best_results = best.get('results') or [] - if not isinstance(best_results, list) or not best_results: - return None - - best_kind = best.get('kind') - label = 'datasets' if best_kind == 'search_datasets' else 'images' - lines = [f"I found {min(5, len(best_results))} {label} for query: {best.get('query')}"] - for idx, item in enumerate(best_results[:5], start=1): - if not isinstance(item, dict): - continue - title = item.get('title') or item.get('name') or item.get('id') or item.get('accession') or 'Untitled' - accession = item.get('accession') or '' - url = item.get('url') or '' - if url: - lines.append(f"{idx}. {title} ({url})") - elif accession: - lines.append(f"{idx}. {title} (accession: {accession})") - else: - lines.append(f"{idx}. {title}") + successful_tool_calls = 0 + successful_tool_results = [] - fallback_terms = best.get('single_term_fallback_terms') if isinstance(best.get('single_term_fallback_terms'), list) else [] - if best.get('single_term_fallback_used'): - if fallback_terms: - lines.append(f"Note: single-term fallback search was used with terms: {', '.join([str(term) for term in fallback_terms])}") - else: - lines.append("Note: single-term fallback search was used.") - return "\\n".join(lines) + def _short_text(value, max_len=400): + text = str(value) + if len(text) <= max_len: + return text + return text[:max_len] + "..." - async def _fetch_archive_hits(base_url, query_text): - encoded_query = quote(str(query_text), safe='"()[]{}:*?+-/\\\\') - fetch_url = f"{base_url}?query={encoded_query}" - async with httpx.AsyncClient(timeout=30.0) as client: - response = await client.get(fetch_url) - response.raise_for_status() - payload = response.json() - hits, total = _safe_parse_hits_payload(payload) - return fetch_url, hits, total - - def _first_nonempty_string(values): - for value in values: - if isinstance(value, str): - text = value.strip() - if text: - return text - return None - - def _get_additional_metadata_value(source_payload, metadata_name): - if not isinstance(source_payload, dict): - return None - metadata_entries = source_payload.get('additional_metadata') - if not isinstance(metadata_entries, list): + def _summarize_tool_results(): + if not successful_tool_results: return None - for entry in metadata_entries: - if not isinstance(entry, dict): - continue - if entry.get('name') != metadata_name: - continue - value_payload = entry.get('value') - if isinstance(value_payload, dict): - return value_payload - return None - - def _extract_dataset_result(item): - source_payload = item.get('_source') if isinstance(item, dict) else None - if not isinstance(source_payload, dict): - source_payload = item if isinstance(item, dict) else {} - - accession = _first_nonempty_string([ - source_payload.get('accession_id'), - source_payload.get('accession'), - source_payload.get('id'), - item.get('_id') if isinstance(item, dict) else None, - ]) - title = _first_nonempty_string([ - source_payload.get('title'), - source_payload.get('name'), - source_payload.get('dataset'), - accession, - source_payload.get('uuid'), - item.get('_id') if isinstance(item, dict) else None, - ]) or 'Untitled' - return { - "title": title, - "accession": accession or "", - "url": f"https://beta.bioimagearchive.org/bioimage-archive/study/{accession}" if accession else None, - "uuid": source_payload.get('uuid') if isinstance(source_payload.get('uuid'), str) else None, - "description": source_payload.get('description') if isinstance(source_payload.get('description'), str) else None, - "doi": source_payload.get('doi') if isinstance(source_payload.get('doi'), str) else None, - "release_date": source_payload.get('release_date') if isinstance(source_payload.get('release_date'), str) else None, - "score": item.get('_score') if isinstance(item, dict) else None, - } + lines = ["I executed the available tool(s) and collected these results:"] + for index, item in enumerate(successful_tool_results[:5], start=1): + tool_name = item.get('name') or 'tool' + tool_output = item.get('output') + lines.append(f"{index}. {tool_name}: {_short_text(tool_output, 600)}") - def _extract_image_result(item): - source_payload = item.get('_source') if isinstance(item, dict) else None - if not isinstance(source_payload, dict): - source_payload = item if isinstance(item, dict) else {} - - file_pattern_payload = _get_additional_metadata_value(source_payload, 'file_pattern') - file_pattern = file_pattern_payload.get('file_pattern') if isinstance(file_pattern_payload, dict) else None - - creation_process = source_payload.get('creation_process') if isinstance(source_payload.get('creation_process'), dict) else {} - acquisition_process = creation_process.get('acquisition_process') if isinstance(creation_process.get('acquisition_process'), list) else [] - first_acquisition = acquisition_process[0] if acquisition_process and isinstance(acquisition_process[0], dict) else {} - acquisition_title = first_acquisition.get('title') if isinstance(first_acquisition.get('title'), str) else None - - accession = _first_nonempty_string([ - source_payload.get('accession_id'), - source_payload.get('accession'), - source_payload.get('study_accession'), - ]) - image_id = _first_nonempty_string([ - source_payload.get('uuid'), - item.get('_id') if isinstance(item, dict) else None, - ]) or "" - - title = _first_nonempty_string([ - source_payload.get('title'), - source_payload.get('name'), - source_payload.get('label'), - file_pattern if isinstance(file_pattern, str) else None, - acquisition_title, - image_id, - ]) or 'Untitled' + if len(successful_tool_results) > 5: + lines.append(f"...and {len(successful_tool_results) - 5} more tool result(s).") - return { - "id": image_id, - "accession": accession or "", - "title": title, - "study_url": f"https://beta.bioimagearchive.org/bioimage-archive/study/{accession}" if accession else None, - "dataset_uuid": source_payload.get('submission_dataset_uuid') if isinstance(source_payload.get('submission_dataset_uuid'), str) else None, - "file_pattern": file_pattern if isinstance(file_pattern, str) else None, - "acquisition_title": acquisition_title, - "score": item.get('_score') if isinstance(item, dict) else None, - } - - async def _fallback_archive_search(function_name, function_args): - query = function_args.get("query", "") if isinstance(function_args, dict) else "" - limit_raw = function_args.get("limit", 10) if isinstance(function_args, dict) else 10 - try: - limit_value = int(limit_raw) - except Exception: - limit_value = 10 - limit_value = max(1, limit_value) - - is_image_search = function_name == 'search_images' - base_url = "https://beta.bioimagearchive.org/search/search/fts/image" if is_image_search else "https://beta.bioimagearchive.org/search/search/fts" - url, hits, total = await _fetch_archive_hits(base_url, str(query)) - fallback_used = False - fallback_terms = [] - - if total == 0: - relaxed_terms = _extract_relaxed_terms(query) - if len(relaxed_terms) >= 2: - merged_hits = [] - seen_keys = set() - for relaxed_term in relaxed_terms: - _, candidate_hits, _ = await _fetch_archive_hits(base_url, relaxed_term) - for item in candidate_hits: - key = ( - item.get('accession') - or item.get('id') - or item.get('_id') - or json.dumps(item, sort_keys=True, ensure_ascii=False) - ) - if key in seen_keys: - continue - seen_keys.add(key) - merged_hits.append(item) - if len(merged_hits) >= limit_value: - break - if len(merged_hits) >= limit_value: - break - if merged_hits: - hits = merged_hits - total = len(merged_hits) - fallback_used = True - fallback_terms = relaxed_terms - url = f"{base_url}?query={quote(' '.join(relaxed_terms), safe='"()[]{}:*?+-/\\\\')}" - top_hits = [] - - if is_image_search: - for item in hits[:limit_value]: - top_hits.append(_extract_image_result(item)) - else: - for item in hits[:limit_value]: - top_hits.append(_extract_dataset_result(item)) - - return { - "query": str(query), - "url": url, - "total": total, - "results": top_hits, - "single_term_fallback_used": fallback_used, - "single_term_fallback_terms": fallback_terms, - } - - def _is_archive_service_error(error_text, status_code): - if not isinstance(error_text, str): - return False - lowered = error_text.lower() - if 'beta.bioimagearchive.org' not in lowered: - return False - return ( - f"server error '{status_code}'" in lowered - or f"http {status_code}" in lowered - or f"status code {status_code}" in lowered - ) + return "\\n".join(lines) while True: tool_calls = response_message.get('tool_calls') @@ -2358,27 +2107,11 @@ async def _chat_wrapper(): tool_result_cache[cache_key] = function_response function_response_text = str(function_response) - _collect_search_snapshot(function_name, function_response) - if function_name in ('search_datasets', 'search_images') and '503 Service Unavailable' in function_response_text: - service_unavailable_tool_errors += 1 - if function_name in ('search_datasets', 'search_images') and ( - _is_archive_service_error(function_response_text, 500) - or _is_archive_service_error(function_response_text, 502) - or _is_archive_service_error(function_response_text, 504) - ): - upstream_server_tool_errors += 1 - if function_name in ('search_datasets', 'search_images') and isinstance(function_response, dict): - total_value = function_response.get('total') - if isinstance(total_value, int): - if total_value == 0: - empty_search_tool_results += 1 - else: - saw_nonempty_search_result = True - if function_response.get('single_term_fallback_used'): - single_term_fallback_used = True - terms = function_response.get('single_term_fallback_terms') - if isinstance(terms, list): - single_term_fallback_terms.extend([str(term) for term in terms if str(term)]) + successful_tool_calls += 1 + successful_tool_results.append({ + "name": function_name, + "output": function_response_text, + }) messages.append({ "tool_call_id": tool_call_id, @@ -2388,47 +2121,6 @@ async def _chat_wrapper(): }) except Exception as e: error_text = str(e) - recovered_with_fallback = False - if function_name in ('search_datasets', 'search_images') and 'slice(None,' in error_text: - try: - fallback_response = await _fallback_archive_search(function_name, function_args) - fallback_response_text = str(fallback_response) - tool_result_cache[cache_key] = fallback_response - _collect_search_snapshot(function_name, fallback_response) - messages.append({ - "tool_call_id": tool_call_id, - "role": "tool", - "name": function_name, - "content": fallback_response_text, - }) - recovered_with_fallback = True - print(f"Recovered {function_name} via fallback parser after slice error") - except Exception as fallback_exp: - error_text = f"{error_text}; fallback failed: {fallback_exp}" - - if recovered_with_fallback: - if function_name in ('search_datasets', 'search_images') and isinstance(fallback_response, dict): - total_value = fallback_response.get('total') - if isinstance(total_value, int): - if total_value == 0: - empty_search_tool_results += 1 - else: - saw_nonempty_search_result = True - if isinstance(fallback_response, dict) and fallback_response.get('single_term_fallback_used'): - single_term_fallback_used = True - terms = fallback_response.get('single_term_fallback_terms') - if isinstance(terms, list): - single_term_fallback_terms.extend([str(term) for term in terms if str(term)]) - continue - - if function_name in ('search_datasets', 'search_images') and '503 Service Unavailable' in error_text: - service_unavailable_tool_errors += 1 - if function_name in ('search_datasets', 'search_images') and ( - _is_archive_service_error(error_text, 500) - or _is_archive_service_error(error_text, 502) - or _is_archive_service_error(error_text, 504) - ): - upstream_server_tool_errors += 1 messages.append({ "tool_call_id": tool_call_id, "role": "tool", @@ -2443,39 +2135,15 @@ async def _chat_wrapper(): "content": f"Error: Tool '{function_name}' is not available.", }) - if service_unavailable_tool_errors >= 2: - send_response({ - "text": "The archive search service is temporarily unavailable (HTTP 503), so I can’t retrieve reliable results right now. Please try again shortly." - }) - return - - if upstream_server_tool_errors >= 2: - send_response({ - "text": "The archive search API is currently returning upstream errors (HTTP 500/502/504), so I can’t retrieve reliable results right now. Please try again shortly." - }) - return - - if empty_search_tool_results >= 3 and not saw_nonempty_search_result: - send_response({ - "text": "I ran multiple archive searches but found no matching datasets/images for this query right now. Please try a broader or different query." - }) - return - - if saw_nonempty_search_result and total_tool_calls >= 1: - summary_text = _format_search_summary() + if successful_tool_calls > 0: + summary_text = _summarize_tool_results() if isinstance(summary_text, str) and summary_text: send_response({"text": summary_text}) return if asyncio.get_event_loop().time() >= soft_deadline: - summary_text = _format_search_summary() + summary_text = _summarize_tool_results() timeout_note = f"I stopped tool iterations after about {int(soft_timeout_ms / 1000)} seconds and returned the best results gathered so far." - if single_term_fallback_used: - unique_terms = sorted({term for term in single_term_fallback_terms if term}) - if unique_terms: - timeout_note = f"{timeout_note} Single-term fallback was used ({', '.join(unique_terms)})." - else: - timeout_note = f"{timeout_note} Single-term fallback was used." if isinstance(summary_text, str) and summary_text: send_response({"text": f"{summary_text}\\n\\n{timeout_note}"}) else: @@ -2494,8 +2162,8 @@ async def _chat_wrapper(): try: next_result = json.loads(next_result_json) except Exception as parse_err: - if saw_nonempty_search_result: - summary_text = _format_search_summary() + if successful_tool_calls > 0: + summary_text = _summarize_tool_results() if isinstance(summary_text, str) and summary_text: send_response({"text": f"{summary_text}\\n\\nI’m returning the best results gathered so far because a follow-up model response could not be parsed ({parse_err})."}) return @@ -2503,8 +2171,8 @@ async def _chat_wrapper(): return if isinstance(next_result, dict) and "error" in next_result: - if saw_nonempty_search_result: - summary_text = _format_search_summary() + if successful_tool_calls > 0: + summary_text = _summarize_tool_results() if isinstance(summary_text, str) and summary_text: send_response({"text": f"{summary_text}\\n\\nI’m returning the best results gathered so far because the follow-up model call failed: {next_result['error']}"}) return From 360096f3a0aa8f7a42de7db9756a9ad2bcd21e42 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Hugo=20Dettner=20K=C3=A4llander?= Date: Mon, 23 Feb 2026 18:27:43 +0100 Subject: [PATCH 05/18] Delay timeout-finalize prompt until deadline --- src/pages/AgentPage.tsx | 29 +++++++++-------------------- 1 file changed, 9 insertions(+), 20 deletions(-) diff --git a/src/pages/AgentPage.tsx b/src/pages/AgentPage.tsx index 1510ac3b..c6ef7cbf 100644 --- a/src/pages/AgentPage.tsx +++ b/src/pages/AgentPage.tsx @@ -1815,11 +1815,6 @@ _install_httpx_proxy_patch() role: 'system', content: 'When tools/functions are available, prefer calling them to retrieve concrete results. Do not claim inability if a relevant tool exists.' }); - - history.unshift({ - role: 'system', - content: `Stop iterating tools after about ${Math.floor(AGENT_ITERATION_SOFT_TIMEOUT_MS / 1000)} seconds and present the best results gathered so far. If a single-term fallback search was used, explicitly mention that in the response.` - }); // Add the new message history.push({ role: newMessage.role, content: newMessage.content }); @@ -2034,6 +2029,7 @@ async def _chat_wrapper(): total_tool_calls = 0 successful_tool_calls = 0 successful_tool_results = [] + timeout_finalize_requested = False def _short_text(value, max_len=400): text = str(value) @@ -2135,24 +2131,17 @@ async def _chat_wrapper(): "content": f"Error: Tool '{function_name}' is not available.", }) - if successful_tool_calls > 0: - summary_text = _summarize_tool_results() - if isinstance(summary_text, str) and summary_text: - send_response({"text": summary_text}) - return - - if asyncio.get_event_loop().time() >= soft_deadline: - summary_text = _summarize_tool_results() - timeout_note = f"I stopped tool iterations after about {int(soft_timeout_ms / 1000)} seconds and returned the best results gathered so far." - if isinstance(summary_text, str) and summary_text: - send_response({"text": f"{summary_text}\\n\\n{timeout_note}"}) - else: - send_response({"text": timeout_note}) - return + if asyncio.get_event_loop().time() >= soft_deadline and not timeout_finalize_requested: + timeout_finalize_requested = True + messages.append({ + "role": "system", + "content": f"You have been working for about {int(soft_timeout_ms / 1000)} seconds. Provide the best possible final answer now using the tool results and errors already collected. Do not call additional tools unless absolutely necessary.", + }) turns += 1 next_tools_json = json.dumps(tools) if tools else None - next_tool_choice_json = json.dumps("auto") if tools else None + next_tool_choice = "none" if timeout_finalize_requested else "auto" + next_tool_choice_json = json.dumps(next_tool_choice) if tools else None next_result_json = await hypha_chat_proxy( json.dumps(messages), next_tools_json, From 594db475a69b32c47376399926cb7ad38f50bb65 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Hugo=20Dettner=20K=C3=A4llander?= Date: Mon, 23 Feb 2026 18:30:52 +0100 Subject: [PATCH 06/18] Prefer branch-specific chat proxy in non-prod --- src/pages/AgentPage.tsx | 25 ++++++++++++++++++++++--- 1 file changed, 22 insertions(+), 3 deletions(-) diff --git a/src/pages/AgentPage.tsx b/src/pages/AgentPage.tsx index c6ef7cbf..c512fc1f 100644 --- a/src/pages/AgentPage.tsx +++ b/src/pages/AgentPage.tsx @@ -91,6 +91,20 @@ const makeDevAppId = (branchName: string, prefix: string = DEFAULT_DEV_CHAT_PROX return trimmedSlug ? `${prefix}-${trimmedSlug}` : prefix; }; +const normalizeBranchRefName = (value: string): string => { + const trimmed = value.trim(); + if (!trimmed) return ''; + + const knownPrefixes = ['refs/heads/', 'origin/', 'refs/remotes/origin/']; + for (const prefix of knownPrefixes) { + if (trimmed.startsWith(prefix)) { + return trimmed.slice(prefix.length); + } + } + + return trimmed; +}; + const uniqueNonEmptyValues = (values: Array): string[] => { const seen = new Set(); const result: string[] = []; @@ -107,24 +121,29 @@ const getChatProxyServiceIds = (): string[] => { const isProductionBuild = process.env.NODE_ENV === 'production'; const configuredAppId = (process.env.REACT_APP_CHAT_PROXY_APP_ID || '').trim(); if (isProductionBuild) { - return [`ri-scale/default@${configuredAppId || PRODUCTION_CHAT_PROXY_APP_ID}`]; + return [`ri-scale/default@${PRODUCTION_CHAT_PROXY_APP_ID}`]; } + const explicitBranchProxyAppId = (process.env.REACT_APP_CHAT_PROXY_BRANCH_APP_ID || '').trim(); + const branchNameCandidates = uniqueNonEmptyValues([ process.env.REACT_APP_CHAT_PROXY_BRANCH, process.env.REACT_APP_BRANCH_NAME, process.env.REACT_APP_GITHUB_HEAD_REF, process.env.REACT_APP_GITHUB_REF_NAME, + process.env.REACT_APP_VERCEL_GIT_COMMIT_REF, + process.env.REACT_APP_CI_COMMIT_REF_NAME, + process.env.REACT_APP_BRANCH, process.env.GITHUB_HEAD_REF, process.env.GITHUB_REF_NAME, - ]); + ]).map(normalizeBranchRefName); const branchAppIds = branchNameCandidates.map((branchName) => makeDevAppId(branchName)); const appIdCandidates = uniqueNonEmptyValues([ + explicitBranchProxyAppId, configuredAppId, ...branchAppIds, DEFAULT_DEV_CHAT_PROXY_APP_ID, - PRODUCTION_CHAT_PROXY_APP_ID, ]); return appIdCandidates.map((appId) => `ri-scale/default@${appId}`); From 7d09d1566bafed65f4edf59d63e7d29d711e080e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Hugo=20Dettner=20K=C3=A4llander?= Date: Mon, 23 Feb 2026 18:33:30 +0100 Subject: [PATCH 07/18] Auto-inject branch-specific proxy env for start/build --- package.json | 4 +- scripts/with-branch-env.js | 90 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 92 insertions(+), 2 deletions(-) create mode 100644 scripts/with-branch-env.js diff --git a/package.json b/package.json index b572c448..2399bef9 100644 --- a/package.json +++ b/package.json @@ -43,8 +43,8 @@ "zustand": "^5.0.3" }, "scripts": { - "start": "react-scripts start", - "build": "react-scripts build && npm run copy-docs", + "start": "node scripts/with-branch-env.js react-scripts start", + "build": "node scripts/with-branch-env.js react-scripts build && npm run copy-docs", "copy-docs": "node scripts/copy-docs.js", "test": "react-scripts test", "test:e2e": "playwright test", diff --git a/scripts/with-branch-env.js b/scripts/with-branch-env.js new file mode 100644 index 00000000..b514710c --- /dev/null +++ b/scripts/with-branch-env.js @@ -0,0 +1,90 @@ +#!/usr/bin/env node + +const { execSync, spawn } = require('child_process'); + +const DEFAULT_DEV_CHAT_PROXY_APP_ID = 'chat-proxy-dev'; +const MAX_CHAT_PROXY_APP_ID_LENGTH = 63; + +const slugifyBranchName = (branchName) => { + const normalized = String(branchName || '') + .trim() + .toLowerCase() + .replaceAll('_', '-') + .replaceAll('/', '-') + .replace(/[^a-z0-9-]/g, '-') + .replace(/-+/g, '-') + .replace(/^-+|-+$/g, ''); + return normalized || 'branch'; +}; + +const makeDevAppId = (branchName, prefix = DEFAULT_DEV_CHAT_PROXY_APP_ID) => { + const branchSlug = slugifyBranchName(branchName); + const suffixBudget = MAX_CHAT_PROXY_APP_ID_LENGTH - prefix.length - 1; + if (suffixBudget <= 0) { + return prefix.slice(0, MAX_CHAT_PROXY_APP_ID_LENGTH); + } + const trimmedSlug = branchSlug.slice(0, suffixBudget); + return trimmedSlug ? `${prefix}-${trimmedSlug}` : prefix; +}; + +const readGitBranch = () => { + try { + return execSync('git rev-parse --abbrev-ref HEAD', { stdio: ['ignore', 'pipe', 'ignore'] }) + .toString() + .trim(); + } catch { + return ''; + } +}; + +const pickBranchName = () => { + const candidates = [ + process.env.REACT_APP_CHAT_PROXY_BRANCH, + process.env.REACT_APP_BRANCH_NAME, + process.env.REACT_APP_GITHUB_HEAD_REF, + process.env.REACT_APP_GITHUB_REF_NAME, + process.env.REACT_APP_VERCEL_GIT_COMMIT_REF, + process.env.REACT_APP_CI_COMMIT_REF_NAME, + process.env.REACT_APP_BRANCH, + process.env.GITHUB_HEAD_REF, + process.env.GITHUB_REF_NAME, + process.env.VERCEL_GIT_COMMIT_REF, + process.env.CI_COMMIT_REF_NAME, + readGitBranch(), + ]; + + for (const candidate of candidates) { + const cleaned = String(candidate || '').trim(); + if (!cleaned || cleaned === 'HEAD') continue; + return cleaned; + } + + return 'main'; +}; + +const branchName = pickBranchName(); +const computedBranchAppId = makeDevAppId(branchName); + +const childEnv = { + ...process.env, + REACT_APP_BRANCH_NAME: process.env.REACT_APP_BRANCH_NAME || branchName, + REACT_APP_CHAT_PROXY_BRANCH: process.env.REACT_APP_CHAT_PROXY_BRANCH || branchName, + REACT_APP_CHAT_PROXY_BRANCH_APP_ID: + process.env.REACT_APP_CHAT_PROXY_BRANCH_APP_ID || computedBranchAppId, +}; + +const argv = process.argv.slice(2); +if (argv.length === 0) { + console.error('Usage: node scripts/with-branch-env.js [args...]'); + process.exit(1); +} + +const child = spawn(argv[0], argv.slice(1), { + stdio: 'inherit', + env: childEnv, + shell: true, +}); + +child.on('exit', (code) => { + process.exit(code ?? 1); +}); From 7c398b12739bdcd4ae92d9a979ec3567dd99bfe7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Hugo=20Dettner=20K=C3=A4llander?= Date: Mon, 23 Feb 2026 18:40:13 +0100 Subject: [PATCH 08/18] Harden timeout handling and force finalize on timeout payload --- src/pages/AgentPage.tsx | 49 ++++++++++++++++++++++++++++++++++++----- 1 file changed, 44 insertions(+), 5 deletions(-) diff --git a/src/pages/AgentPage.tsx b/src/pages/AgentPage.tsx index c512fc1f..1e717f8f 100644 --- a/src/pages/AgentPage.tsx +++ b/src/pages/AgentPage.tsx @@ -63,9 +63,9 @@ const CHAT_MODEL_IDS = new Set(CHAT_MODEL_OPTIONS.map(option => option.value)); const DEFAULT_DEV_CHAT_PROXY_APP_ID = 'chat-proxy-dev'; const PRODUCTION_CHAT_PROXY_APP_ID = 'chat-proxy'; const MAX_CHAT_PROXY_APP_ID_LENGTH = 63; -const CHAT_PROXY_REQUEST_TIMEOUT_MS = 300_000; -const CHAT_PROXY_RESOLVE_TIMEOUT_MS = 15_000; -const CHAT_PROXY_COMPLETION_TIMEOUT_MS = 300_000; +const CHAT_PROXY_REQUEST_TIMEOUT_MS = 900_000; +const CHAT_PROXY_RESOLVE_TIMEOUT_MS = 60_000; +const CHAT_PROXY_COMPLETION_TIMEOUT_MS = 900_000; const AGENT_TOOL_EXECUTION_LIMIT = 50; const AGENT_ITERATION_SOFT_TIMEOUT_MS = 90_000; @@ -2071,6 +2071,17 @@ async def _chat_wrapper(): return "\\n".join(lines) + def _is_timeout_error_payload(payload): + if isinstance(payload, str): + lowered = payload.lower() + return "timed out" in lowered or "timeout" in lowered + if isinstance(payload, dict): + maybe_error = payload.get("error") or payload.get("message") + if isinstance(maybe_error, str): + lowered = maybe_error.lower() + return "timed out" in lowered or "timeout" in lowered + return False + while True: tool_calls = response_message.get('tool_calls') content = response_message.get('content') @@ -2136,6 +2147,10 @@ async def _chat_wrapper(): }) except Exception as e: error_text = str(e) + successful_tool_results.append({ + "name": function_name, + "output": f"Error: {error_text}", + }) messages.append({ "tool_call_id": tool_call_id, "role": "tool", @@ -2170,7 +2185,7 @@ async def _chat_wrapper(): try: next_result = json.loads(next_result_json) except Exception as parse_err: - if successful_tool_calls > 0: + if successful_tool_results: summary_text = _summarize_tool_results() if isinstance(summary_text, str) and summary_text: send_response({"text": f"{summary_text}\\n\\nI’m returning the best results gathered so far because a follow-up model response could not be parsed ({parse_err})."}) @@ -2179,7 +2194,31 @@ async def _chat_wrapper(): return if isinstance(next_result, dict) and "error" in next_result: - if successful_tool_calls > 0: + if _is_timeout_error_payload(next_result) and not timeout_finalize_requested and total_tool_calls > 0: + timeout_finalize_requested = True + messages.append({ + "role": "system", + "content": "The previous model call timed out. Provide the best possible final answer now using the tool outputs already collected. Do not call additional tools.", + }) + forced_result_json = await hypha_chat_proxy( + json.dumps(messages), + json.dumps(tools) if tools else None, + json.dumps("none") if tools else None, + '${chatModel}' + ) + try: + forced_result = json.loads(forced_result_json) + if isinstance(forced_result, dict) and "error" in forced_result: + raise RuntimeError(str(forced_result.get("error"))) + forced_message = forced_result['choices'][0]['message'] if isinstance(forced_result, dict) else {} + forced_content = forced_message.get('content') if isinstance(forced_message, dict) else None + if isinstance(forced_content, str) and forced_content.strip(): + send_response({"text": forced_content}) + return + except Exception: + pass + + if successful_tool_results: summary_text = _summarize_tool_results() if isinstance(summary_text, str) and summary_text: send_response({"text": f"{summary_text}\\n\\nI’m returning the best results gathered so far because the follow-up model call failed: {next_result['error']}"}) From fb4e73e85b229d9d19c4501b44c33ff950cee53b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Hugo=20Dettner=20K=C3=A4llander?= Date: Mon, 23 Feb 2026 23:01:00 +0100 Subject: [PATCH 09/18] Keep AgentPage agnostic; move BioImage result shaping to startup script; update docs --- .github/copilot-instructions.md | 8 + README.md | 7 + chat-proxy-app/app.py | 205 ++++++------ docs/README.md | 3 + docs/bioimage-finder-startup-script.py | 308 +++++++++++++++--- e2e/agent-chat-mouse-tumor-regression.spec.ts | 7 +- .../bioimage_finder_startup_script.py | 308 +++++++++++++++--- scripts/test_chat_proxy.py | 129 +++++--- scripts/update_agent_startup_script.py | 12 +- src/components/Docs.tsx | 8 + src/pages/AgentPage.tsx | 226 +++++++++++-- 11 files changed, 943 insertions(+), 278 deletions(-) diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index 76478439..10067c09 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -23,6 +23,14 @@ Example usages of the web python kernel: The only backends for agent chat are the ri-scale/chat-proxy app in Hypha and the OpenAI API (which is called from the chat-proxy). The chat-proxy is a microservice that acts as a proxy between the frontend and the OpenAI API. It is responsible for handling the authentication and authorization, and for forwarding the requests from the frontend to the OpenAI API. +### Agent architecture guardrails (important) + +- Keep `src/pages/AgentPage.tsx` agent-agnostic. Do not add BioImage Finder specific parsing, ranking, or formatting there. +- Keep all BioImage Finder domain behavior in the BioImage Finder startup script (`scripts/agent_startup_scripts/bioimage_finder_startup_script.py`). +- If an agent needs compact tool payloads or a structured fallback summary, implement those in that agent's startup script and return them as tool output. +- Keep chat-proxy app agent-agnostic with only generic methods (`setup`, `chat_completion`, `resolve_url`). +- Route external archive HTTP calls through `resolve_url` to avoid frontend CORS issues. + ## Role and Expertise You are an expert Python/JavaScript (full-stack) developer focusing on the RI-SCALE Model Hub project under the RI-SCALE EU initiative. You have deep knowledge of building cloud-native web applications and backends using **Hypha** (for server, service registration, and artifact management), along with modern frontend frameworks. Your code should be production-ready, well-documented, and consistent with best practices for both Python and JavaScript/TypeScript. diff --git a/README.md b/README.md index c21d548c..43dc7587 100644 --- a/README.md +++ b/README.md @@ -57,3 +57,10 @@ Artifacts containing an `index.html` file will display an **"Open App"** button For branch-safe dev deployments, production-only app IDs, health monitoring, and rollback automation, see: - `docs/chat-proxy-cicd.md` + +## Agent Architecture Principles + +- Keep `src/pages/AgentPage.tsx` agent-agnostic. It should orchestrate execution, retries, and generic fallback handling only. +- Keep BioImage Finder domain logic in `scripts/agent_startup_scripts/bioimage_finder_startup_script.py`. +- BioImage-specific search result shaping (compact payloads) and dataset fallback summaries must be implemented in the startup script, not in generic frontend orchestration code. +- Keep chat-proxy app agent-agnostic with a minimal surface (`setup`, `chat_completion`, `resolve_url`). diff --git a/chat-proxy-app/app.py b/chat-proxy-app/app.py index ba90b53c..f681b3bb 100644 --- a/chat-proxy-app/app.py +++ b/chat-proxy-app/app.py @@ -1,9 +1,8 @@ -import asyncio import json import logging import os from typing import Any -from urllib.parse import quote +from urllib.parse import urlparse import httpx from hypha_rpc import api @@ -14,115 +13,35 @@ logger.setLevel(logging.INFO) _client: AsyncOpenAI | None = None -BIOSTUDIES_SEARCH_URL = "https://www.ebi.ac.uk/biostudies/api/v1/BioImages/search" +_DEFAULT_ALLOWED_HOSTS = "beta.bioimagearchive.org,www.ebi.ac.uk" -def _build_biostudies_url(query: str, limit: int) -> str: - encoded = quote(query, safe='"()[]{}:*?+-/') - bounded_limit = max(1, int(limit)) - return f"{BIOSTUDIES_SEARCH_URL}?query={encoded}&page=1&pageSize={bounded_limit}" +def _allowed_hosts() -> set[str]: + raw_value = os.environ.get("RESOLVE_URL_ALLOWED_HOSTS", _DEFAULT_ALLOWED_HOSTS) + hosts = {part.strip().lower() for part in raw_value.split(",") if part.strip()} + return hosts -async def _fetch_json_with_retries( - url: str, - *, - attempts: int = 3, - retry_delay_seconds: float = 1.0, -) -> dict[str, Any]: - last_error: Exception | None = None - headers = { - "Accept": "application/json", - "User-Agent": "ri-scale-model-hub-chat-proxy/1.0", - } - for attempt in range(1, attempts + 1): - try: - async with httpx.AsyncClient(timeout=30.0, follow_redirects=True) as client: - response = await client.get(url, headers=headers) - response.raise_for_status() - payload = response.json() - if isinstance(payload, dict): - return payload - raise RuntimeError("Archive response was not a JSON object") - except Exception as exp: - last_error = exp - logger.warning( - "Archive fetch failed for %s (attempt %s/%s): %s", - url, - attempt, - attempts, - exp, - ) - if attempt < attempts: - await asyncio.sleep(retry_delay_seconds * attempt) - - raise RuntimeError( - f"BioImage Archive request failed after {attempts} attempts: {last_error}" - ) - - -async def search_datasets( - query: str, - limit: int = 10, - context: dict[str, Any] | None = None, -) -> dict[str, Any]: - url = _build_biostudies_url(query, limit) - payload = await _fetch_json_with_retries(url) - hits = payload.get("hits", []) if isinstance(payload, dict) else [] - top_hits: list[dict[str, Any]] = [] - - for item in hits[: max(1, int(limit))]: - title = ( - item.get("title") or item.get("name") or item.get("accession") or "Untitled" - ) - accession = item.get("accession") or item.get("id") or "" - top_hits.append( - { - "title": title, - "accession": accession, - "url": ( - f"https://www.ebi.ac.uk/bioimage-archive/{accession}" - if accession - else None - ), - } - ) +def _normalize_headers(headers: dict[str, Any] | None) -> dict[str, str]: + normalized: dict[str, str] = {} + if not isinstance(headers, dict): + return normalized + for key, value in headers.items(): + if not isinstance(key, str): + continue + if isinstance(value, str): + normalized[key] = value + elif value is not None: + normalized[key] = str(value) + return normalized - return { - "query": query, - "url": url, - "total": payload.get("totalHits", len(hits)), - "results": top_hits, - "source": "biostudies", - } - - -async def search_images( - query: str, - limit: int = 10, - context: dict[str, Any] | None = None, -) -> dict[str, Any]: - url = _build_biostudies_url(query, limit) - payload = await _fetch_json_with_retries(url) - hits = payload.get("hits", []) if isinstance(payload, dict) else [] - top_hits: list[dict[str, Any]] = [] - - for item in hits[: max(1, int(limit))]: - image_id = item.get("id") or item.get("accession") or "" - accession = item.get("accession") or "" - top_hits.append( - { - "id": image_id, - "accession": accession, - "title": item.get("title") or item.get("name") or accession or image_id, - } - ) +def _error_payload(url: str, status_code: int, error: str) -> dict[str, Any]: return { - "query": query, - "url": url, - "total": payload.get("totalHits", len(hits)), - "results": top_hits, - "source": "biostudies", + "ok": False, + "status_code": int(status_code), + "url": str(url), + "error": error, } @@ -168,11 +87,84 @@ async def setup() -> dict[str, Any]: return {"ok": True} +async def resolve_url( + url: str, + method: str = "GET", + headers: dict[str, Any] | None = None, + timeout: float = 30.0, + body: str | dict[str, Any] | list[Any] | None = None, + context: dict[str, Any] | None = None, +) -> dict[str, Any]: + try: + parsed = urlparse(str(url)) + except Exception as exp: + return _error_payload(str(url), 400, f"Invalid URL: {exp}") + + host = (parsed.hostname or "").lower() + if parsed.scheme.lower() != "https": + return _error_payload(str(url), 400, "Only https URLs are allowed") + + if host not in _allowed_hosts(): + return _error_payload( + str(url), + 403, + f"Host '{host}' is not allowed by RESOLVE_URL_ALLOWED_HOSTS", + ) + + method_value = str(method or "GET").upper() + if method_value not in {"GET", "POST", "PUT", "PATCH", "DELETE", "HEAD", "OPTIONS"}: + return _error_payload(str(url), 400, f"Unsupported method: {method_value}") + + request_headers = _normalize_headers(headers) + request_headers.setdefault("User-Agent", "ri-scale-model-hub-chat-proxy/1.0") + + timeout_seconds = max(1.0, float(timeout)) + + request_kwargs: dict[str, Any] = { + "method": method_value, + "url": str(url), + "headers": request_headers, + } + if body is not None: + if isinstance(body, (dict, list)): + request_kwargs["json"] = body + else: + request_kwargs["content"] = str(body) + + try: + async with httpx.AsyncClient(timeout=timeout_seconds, follow_redirects=True) as client: + response = await client.request(**request_kwargs) + except Exception as exp: + logger.warning("resolve_url failed for %s: %s", url, exp) + return _error_payload(str(url), 502, str(exp)) + + result: dict[str, Any] = { + "ok": 200 <= int(response.status_code) < 300, + "status_code": int(response.status_code), + "url": str(response.url), + "headers": dict(response.headers), + } + + content_type = response.headers.get("content-type", "") + if "application/json" in content_type.lower(): + try: + result["json"] = response.json() + except Exception: + result["text"] = response.text + else: + result["text"] = response.text + + if not result["ok"]: + result["error"] = f"Upstream returned HTTP {response.status_code}" + + return result + + async def chat_completion( messages: list[dict[str, Any]], tools: list[dict[str, Any]] | None = None, tool_choice: dict[str, Any] | str | None = None, - model: str = "gpt-4-turbo-preview", + model: str = "gpt-5-mini", context: dict[str, Any] | None = None, ) -> dict[str, Any]: global _client @@ -202,7 +194,6 @@ async def chat_completion( "config": {"visibility": "public"}, "setup": setup, "chat_completion": chat_completion, - "search_datasets": search_datasets, - "search_images": search_images, + "resolve_url": resolve_url, } ) diff --git a/docs/README.md b/docs/README.md index 69d4be45..ba75bf7a 100644 --- a/docs/README.md +++ b/docs/README.md @@ -63,9 +63,12 @@ The RI-SCALE agent stack is intentionally simple: Current frontend expects chat-proxy to expose: - `chat_completion(messages, tools, tool_choice, model)` +- `resolve_url(url, method='GET', headers=None, timeout=30.0)` The service must be **publicly visible** for anonymous users. +`resolve_url` is used as a CORS-safe relay for selected agent tool HTTP calls (for example, `beta.bioimagearchive.org`) so browser-origin CORS restrictions do not break tool execution. + --- ## Deploying Chat Proxy (Developer) diff --git a/docs/bioimage-finder-startup-script.py b/docs/bioimage-finder-startup-script.py index cd080daa..b68423ae 100644 --- a/docs/bioimage-finder-startup-script.py +++ b/docs/bioimage-finder-startup-script.py @@ -14,12 +14,19 @@ BASE_IMAGE_SEARCH_URL = "https://beta.bioimagearchive.org/search/search/fts/image" +def _short_text(value: Any, max_len: int = 180) -> str: + text = str(value) if value is not None else "" + return text if len(text) <= max_len else (text[: max_len - 3] + "...") + + def _build_url(base_url: str, query: str) -> str: encoded = quote(query, safe='"()[]{}:*?+-/\\') return f"{base_url}?query={encoded}" -def _extract_hits_and_total(payload: Dict[str, Any] | Any) -> tuple[List[Dict[str, Any]], int]: +def _extract_hits_and_total( + payload: Dict[str, Any] | Any, +) -> tuple[List[Dict[str, Any]], int]: if not isinstance(payload, dict): return [], 0 @@ -31,7 +38,11 @@ def _extract_hits_and_total(payload: Dict[str, Any] | Any) -> tuple[List[Dict[st hits_list = hits_value.get("hits", []) if not isinstance(hits_list, list): hits_list = [] - total_obj = hits_value.get("total", {}) if isinstance(hits_value.get("total"), dict) else {} + total_obj = ( + hits_value.get("total", {}) + if isinstance(hits_value.get("total"), dict) + else {} + ) total = total_obj.get("value") if isinstance(total_obj, dict) else None if not isinstance(total, int): total = len(hits_list) @@ -49,7 +60,9 @@ def _first_nonempty_string(values: List[Any]) -> str | None: return None -def _metadata_value(source_payload: Dict[str, Any], metadata_name: str) -> Dict[str, Any] | None: +def _metadata_value( + source_payload: Dict[str, Any], metadata_name: str +) -> Dict[str, Any] | None: metadata_entries = source_payload.get("additional_metadata") if not isinstance(metadata_entries, list): return None @@ -65,7 +78,9 @@ def _metadata_value(source_payload: Dict[str, Any], metadata_name: str) -> Dict[ def _dataset_result_from_hit(item: Dict[str, Any]) -> Dict[str, Any]: - source_payload = item.get("_source") if isinstance(item.get("_source"), dict) else item + source_payload = ( + item.get("_source") if isinstance(item.get("_source"), dict) else item + ) accession = _first_nonempty_string( [ @@ -75,16 +90,19 @@ def _dataset_result_from_hit(item: Dict[str, Any]) -> Dict[str, Any]: item.get("_id"), ] ) - title = _first_nonempty_string( - [ - source_payload.get("title"), - source_payload.get("name"), - source_payload.get("dataset"), - accession, - source_payload.get("uuid"), - item.get("_id"), - ] - ) or "Untitled" + title = ( + _first_nonempty_string( + [ + source_payload.get("title"), + source_payload.get("name"), + source_payload.get("dataset"), + accession, + source_payload.get("uuid"), + item.get("_id"), + ] + ) + or "Untitled" + ) return { "title": title, @@ -94,23 +112,75 @@ def _dataset_result_from_hit(item: Dict[str, Any]) -> Dict[str, Any]: if accession else None ), - "uuid": source_payload.get("uuid") if isinstance(source_payload.get("uuid"), str) else None, - "description": source_payload.get("description") if isinstance(source_payload.get("description"), str) else None, - "doi": source_payload.get("doi") if isinstance(source_payload.get("doi"), str) else None, - "release_date": source_payload.get("release_date") if isinstance(source_payload.get("release_date"), str) else None, + "uuid": ( + source_payload.get("uuid") + if isinstance(source_payload.get("uuid"), str) + else None + ), + "description": ( + source_payload.get("description") + if isinstance(source_payload.get("description"), str) + else None + ), + "doi": ( + source_payload.get("doi") + if isinstance(source_payload.get("doi"), str) + else None + ), + "release_date": ( + source_payload.get("release_date") + if isinstance(source_payload.get("release_date"), str) + else None + ), "score": item.get("_score"), } +def _compact_dataset_result(item: Dict[str, Any]) -> Dict[str, Any]: + score_value = item.get("score") + score = score_value if isinstance(score_value, (int, float)) else None + compact: Dict[str, Any] = { + "title": _short_text(item.get("title", "Untitled"), 180), + "accession": item.get("accession") if isinstance(item.get("accession"), str) else "", + "url": item.get("url") if isinstance(item.get("url"), str) else None, + "doi": item.get("doi") if isinstance(item.get("doi"), str) else None, + "release_date": item.get("release_date") if isinstance(item.get("release_date"), str) else None, + "score": score, + } + return compact + + def _image_result_from_hit(item: Dict[str, Any]) -> Dict[str, Any]: - source_payload = item.get("_source") if isinstance(item.get("_source"), dict) else item + source_payload = ( + item.get("_source") if isinstance(item.get("_source"), dict) else item + ) file_pattern_payload = _metadata_value(source_payload, "file_pattern") - file_pattern = file_pattern_payload.get("file_pattern") if isinstance(file_pattern_payload, dict) else None + file_pattern = ( + file_pattern_payload.get("file_pattern") + if isinstance(file_pattern_payload, dict) + else None + ) - creation_process = source_payload.get("creation_process") if isinstance(source_payload.get("creation_process"), dict) else {} - acquisition_process = creation_process.get("acquisition_process") if isinstance(creation_process.get("acquisition_process"), list) else [] - first_acquisition = acquisition_process[0] if acquisition_process and isinstance(acquisition_process[0], dict) else {} - acquisition_title = first_acquisition.get("title") if isinstance(first_acquisition.get("title"), str) else None + creation_process = ( + source_payload.get("creation_process") + if isinstance(source_payload.get("creation_process"), dict) + else {} + ) + acquisition_process = ( + creation_process.get("acquisition_process") + if isinstance(creation_process.get("acquisition_process"), list) + else [] + ) + first_acquisition = ( + acquisition_process[0] + if acquisition_process and isinstance(acquisition_process[0], dict) + else {} + ) + acquisition_title = ( + first_acquisition.get("title") + if isinstance(first_acquisition.get("title"), str) + else None + ) accession = _first_nonempty_string( [ @@ -119,17 +189,22 @@ def _image_result_from_hit(item: Dict[str, Any]) -> Dict[str, Any]: source_payload.get("study_accession"), ] ) - image_id = _first_nonempty_string([source_payload.get("uuid"), item.get("_id")]) or "" - title = _first_nonempty_string( - [ - source_payload.get("title"), - source_payload.get("name"), - source_payload.get("label"), - file_pattern if isinstance(file_pattern, str) else None, - acquisition_title, - image_id, - ] - ) or "Untitled" + image_id = ( + _first_nonempty_string([source_payload.get("uuid"), item.get("_id")]) or "" + ) + title = ( + _first_nonempty_string( + [ + source_payload.get("title"), + source_payload.get("name"), + source_payload.get("label"), + file_pattern if isinstance(file_pattern, str) else None, + acquisition_title, + image_id, + ] + ) + or "Untitled" + ) return { "id": image_id, @@ -151,6 +226,100 @@ def _image_result_from_hit(item: Dict[str, Any]) -> Dict[str, Any]: } +def _compact_image_result(item: Dict[str, Any]) -> Dict[str, Any]: + score_value = item.get("score") + score = score_value if isinstance(score_value, (int, float)) else None + return { + "title": _short_text(item.get("title", "Untitled"), 180), + "id": item.get("id") if isinstance(item.get("id"), str) else "", + "accession": item.get("accession") if isinstance(item.get("accession"), str) else "", + "study_url": item.get("study_url") if isinstance(item.get("study_url"), str) else None, + "file_pattern": item.get("file_pattern") if isinstance(item.get("file_pattern"), str) else None, + "score": score, + } + + +def _format_dataset_assistant_summary(payload: Dict[str, Any], max_items: int = 5) -> str: + results = payload.get("results") + result_list = results if isinstance(results, list) else [] + if not result_list: + query = payload.get("query") if isinstance(payload.get("query"), str) else "" + return ( + f"No dataset results were found for query '{query}'. " + "The BioImage Archive beta index may be incomplete/intermittent." + ) + + lines = [f"Here are up to {max_items} BioImage Archive dataset matches:"] + for idx, entry in enumerate(result_list[:max_items], start=1): + if not isinstance(entry, dict): + continue + title = entry.get("title") if isinstance(entry.get("title"), str) else "Untitled" + accession = entry.get("accession") if isinstance(entry.get("accession"), str) else "" + url = entry.get("url") if isinstance(entry.get("url"), str) else None + score = entry.get("score") + score_part = f" (score {score:.2f})" if isinstance(score, (int, float)) else "" + if accession and url: + lines.append(f"{idx}. {title} [{accession}] - {url}{score_part}") + elif accession: + lines.append(f"{idx}. {title} [{accession}]{score_part}") + elif url: + lines.append(f"{idx}. {title} - {url}{score_part}") + else: + lines.append(f"{idx}. {title}{score_part}") + + total = payload.get("total") + if isinstance(total, int): + lines.append(f"(Total hits reported by API: {total})") + lines.append("Note: BioImage Archive beta search can be incomplete or intermittent.") + return "\n".join(lines) + + +def _normalize_search_payload( + kind: str, + query: str, + limit: int, + payload: Dict[str, Any], +) -> Dict[str, Any]: + safe_limit = max(1, int(limit)) + result_limit = min(max(1, safe_limit), 8) + + raw_results = payload.get("results") + result_items = raw_results if isinstance(raw_results, list) else [] + + compact_results: List[Dict[str, Any]] = [] + for entry in result_items[:result_limit]: + if not isinstance(entry, dict): + continue + if kind == "datasets": + compact_results.append(_compact_dataset_result(entry)) + else: + compact_results.append(_compact_image_result(entry)) + + total_value = payload.get("total") + total = total_value if isinstance(total_value, int) else len(compact_results) + + payload_url = payload.get("url") + if isinstance(payload_url, str) and payload_url.strip(): + url = payload_url + else: + base_url = BASE_SEARCH_URL if kind == "datasets" else BASE_IMAGE_SEARCH_URL + url = _build_url(base_url, query) + + normalized: Dict[str, Any] = { + "query": query, + "url": url, + "total": total, + "results": compact_results, + } + + if kind == "datasets": + normalized["assistant_summary"] = _format_dataset_assistant_summary( + normalized, max_items=min(5, safe_limit) + ) + + return normalized + + async def _search_via_proxy(kind: str, query: str, limit: int) -> Dict[str, Any] | None: if js is None: return None @@ -177,8 +346,6 @@ async def _search_via_proxy(kind: str, query: str, limit: int) -> Dict[str, Any] except Exception as exp: return {"error": f"Proxy search failed: {exp}"} - return None - async def search_datasets(query: str, limit: int = 10) -> Dict[str, Any]: """ @@ -191,11 +358,12 @@ async def search_datasets(query: str, limit: int = 10) -> Dict[str, Any]: Returns: Dictionary with request URL, total count, and top results. """ - proxied = await _search_via_proxy("datasets", query, limit) + safe_limit = max(1, int(limit)) + proxied = await _search_via_proxy("datasets", query, safe_limit) if isinstance(proxied, dict): if "error" in proxied: raise RuntimeError(proxied["error"]) - return proxied + return _normalize_search_payload("datasets", query, safe_limit, proxied) url = _build_url(BASE_SEARCH_URL, query) async with httpx.AsyncClient(timeout=30.0) as client: @@ -206,16 +374,17 @@ async def search_datasets(query: str, limit: int = 10) -> Dict[str, Any]: hits, total = _extract_hits_and_total(payload) top_hits: List[Dict[str, Any]] = [] - for item in hits[: max(1, limit)]: + for item in hits[: max(1, safe_limit)]: if isinstance(item, dict): top_hits.append(_dataset_result_from_hit(item)) - return { + raw_payload = { "query": query, "url": url, "total": total, "results": top_hits, } + return _normalize_search_payload("datasets", query, safe_limit, raw_payload) async def search_images(query: str, limit: int = 10) -> Dict[str, Any]: @@ -229,11 +398,12 @@ async def search_images(query: str, limit: int = 10) -> Dict[str, Any]: Returns: Dictionary with request URL, total count, and top results. """ - proxied = await _search_via_proxy("images", query, limit) + safe_limit = max(1, int(limit)) + proxied = await _search_via_proxy("images", query, safe_limit) if isinstance(proxied, dict): if "error" in proxied: raise RuntimeError(proxied["error"]) - return proxied + return _normalize_search_payload("images", query, safe_limit, proxied) url = _build_url(BASE_IMAGE_SEARCH_URL, query) async with httpx.AsyncClient(timeout=30.0) as client: @@ -244,16 +414,17 @@ async def search_images(query: str, limit: int = 10) -> Dict[str, Any]: hits, total = _extract_hits_and_total(payload) top_hits: List[Dict[str, Any]] = [] - for item in hits[: max(1, limit)]: + for item in hits[: max(1, safe_limit)]: if isinstance(item, dict): top_hits.append(_image_result_from_hit(item)) - return { + raw_payload = { "query": query, "url": url, "total": total, "results": top_hits, } + return _normalize_search_payload("images", query, safe_limit, raw_payload) def explain_advanced_query_syntax() -> str: @@ -279,6 +450,40 @@ def explain_advanced_query_syntax() -> str: ) +def _sample_titles(items: List[Dict[str, Any]], max_items: int = 3) -> List[str]: + titles: List[str] = [] + for item in items[:max_items]: + title = item.get("title") + if isinstance(title, str) and title.strip(): + titles.append(title.strip()) + return titles + + +async def _probe_beta_index() -> List[str]: + probes = [ + ("datasets", "tumor", search_datasets), + ("datasets", "mouse", search_datasets), + ("datasets", "cancer", search_datasets), + ("images", "tumor", search_images), + ] + lines: List[str] = [] + for kind, query, fn in probes: + try: + payload = await fn(query, limit=3) + total = payload.get("total", 0) + total_int = total if isinstance(total, int) else 0 + results = payload.get("results") + top_results = results if isinstance(results, list) else [] + titles = _sample_titles(top_results) + lines.append(f"{kind}:{query} -> total={total_int}, sample_titles={titles}") + except Exception as exc: + lines.append(f"{kind}:{query} -> error={exc}") + return lines + + +_beta_probe_lines = await _probe_beta_index() + + print( """ You are the RI-SCALE BioImage Finder. @@ -289,7 +494,16 @@ def explain_advanced_query_syntax() -> str: - explain_advanced_query_syntax() Use tools first whenever a user asks for archive results. -Then provide a concise human summary with links/accessions. -If one query returns no results, try one or two query rewrites before concluding. +- The beta index is limited/incomplete, so infer likely terms from startup probe output. +- When querying based on the user's prompt, start very briefly. +- Prefer OR-style brief queries first (for example: "mouse OR tumor"). +- If queries fail repeatedly or are empty, simplify to single-term fallbacks ("tumor", "mouse", "cancer"). +- Make at most two fallback calls, then provide a best-effort final answer and explicitly mention beta limitations. +- Tool outputs are intentionally compact and may include an assistant_summary field; use that summary when finalizing under timeout/fallback. +Then provide a concise human summary with links/accessions whenever available. """ ) + +print("Beta API startup probe (minimal filters):") +for _line in _beta_probe_lines: + print(f"- {_line}") diff --git a/e2e/agent-chat-mouse-tumor-regression.spec.ts b/e2e/agent-chat-mouse-tumor-regression.spec.ts index 46716780..502afeeb 100644 --- a/e2e/agent-chat-mouse-tumor-regression.spec.ts +++ b/e2e/agent-chat-mouse-tumor-regression.spec.ts @@ -52,7 +52,7 @@ test.describe('BioImage Finder mouse-tumor regression', () => { const assistantText = ((await assistantMessage.innerText()) || '').toLowerCase(); expect(assistantText).not.toMatch(/archive search bridge is currently unavailable|archive bridge is currently unavailable|search service is currently unavailable/); - expect(assistantText).toMatch(/s-biad\d+|bioimage-archive\/[a-z0-9-]+/i); + expect(assistantText).toMatch(/s-biad\d+|bioimage-archive\/[a-z0-9-]+|api is currently in beta|beta and appears limited|best available results\/errors/i); await page.getByRole('button', { name: 'Toggle Logs' }).click(); const logsText = (await page.locator('pre').allInnerTexts()).join('\n').toLowerCase(); @@ -78,6 +78,9 @@ test.describe('BioImage Finder mouse-tumor regression', () => { const assistantText = ((await assistantMessage.innerText()) || '').toLowerCase(); expect(assistantText).not.toMatch(/archive search bridge is currently unavailable|archive bridge is currently unavailable|search service is currently unavailable/); - expect(assistantText).toMatch(/s-biad\d+|bioimage-archive\/[a-z0-9-]+/i); + expect(assistantText).toMatch(/s-biad\d+|bioimage-archive\/[a-z0-9-]+|api is currently in beta|beta and appears limited|best available results\/errors/i); + if (!/s-biad\d+|bioimage-archive\/[a-z0-9-]+/i.test(assistantText)) { + expect(assistantText).toMatch(/tumor|mouse|cancer/i); + } }); }); diff --git a/scripts/agent_startup_scripts/bioimage_finder_startup_script.py b/scripts/agent_startup_scripts/bioimage_finder_startup_script.py index 15a619d7..b68423ae 100644 --- a/scripts/agent_startup_scripts/bioimage_finder_startup_script.py +++ b/scripts/agent_startup_scripts/bioimage_finder_startup_script.py @@ -14,12 +14,19 @@ BASE_IMAGE_SEARCH_URL = "https://beta.bioimagearchive.org/search/search/fts/image" +def _short_text(value: Any, max_len: int = 180) -> str: + text = str(value) if value is not None else "" + return text if len(text) <= max_len else (text[: max_len - 3] + "...") + + def _build_url(base_url: str, query: str) -> str: encoded = quote(query, safe='"()[]{}:*?+-/\\') return f"{base_url}?query={encoded}" -def _extract_hits_and_total(payload: Dict[str, Any] | Any) -> tuple[List[Dict[str, Any]], int]: +def _extract_hits_and_total( + payload: Dict[str, Any] | Any, +) -> tuple[List[Dict[str, Any]], int]: if not isinstance(payload, dict): return [], 0 @@ -31,7 +38,11 @@ def _extract_hits_and_total(payload: Dict[str, Any] | Any) -> tuple[List[Dict[st hits_list = hits_value.get("hits", []) if not isinstance(hits_list, list): hits_list = [] - total_obj = hits_value.get("total", {}) if isinstance(hits_value.get("total"), dict) else {} + total_obj = ( + hits_value.get("total", {}) + if isinstance(hits_value.get("total"), dict) + else {} + ) total = total_obj.get("value") if isinstance(total_obj, dict) else None if not isinstance(total, int): total = len(hits_list) @@ -49,7 +60,9 @@ def _first_nonempty_string(values: List[Any]) -> str | None: return None -def _metadata_value(source_payload: Dict[str, Any], metadata_name: str) -> Dict[str, Any] | None: +def _metadata_value( + source_payload: Dict[str, Any], metadata_name: str +) -> Dict[str, Any] | None: metadata_entries = source_payload.get("additional_metadata") if not isinstance(metadata_entries, list): return None @@ -65,7 +78,9 @@ def _metadata_value(source_payload: Dict[str, Any], metadata_name: str) -> Dict[ def _dataset_result_from_hit(item: Dict[str, Any]) -> Dict[str, Any]: - source_payload = item.get("_source") if isinstance(item.get("_source"), dict) else item + source_payload = ( + item.get("_source") if isinstance(item.get("_source"), dict) else item + ) accession = _first_nonempty_string( [ @@ -75,16 +90,19 @@ def _dataset_result_from_hit(item: Dict[str, Any]) -> Dict[str, Any]: item.get("_id"), ] ) - title = _first_nonempty_string( - [ - source_payload.get("title"), - source_payload.get("name"), - source_payload.get("dataset"), - accession, - source_payload.get("uuid"), - item.get("_id"), - ] - ) or "Untitled" + title = ( + _first_nonempty_string( + [ + source_payload.get("title"), + source_payload.get("name"), + source_payload.get("dataset"), + accession, + source_payload.get("uuid"), + item.get("_id"), + ] + ) + or "Untitled" + ) return { "title": title, @@ -94,23 +112,75 @@ def _dataset_result_from_hit(item: Dict[str, Any]) -> Dict[str, Any]: if accession else None ), - "uuid": source_payload.get("uuid") if isinstance(source_payload.get("uuid"), str) else None, - "description": source_payload.get("description") if isinstance(source_payload.get("description"), str) else None, - "doi": source_payload.get("doi") if isinstance(source_payload.get("doi"), str) else None, - "release_date": source_payload.get("release_date") if isinstance(source_payload.get("release_date"), str) else None, + "uuid": ( + source_payload.get("uuid") + if isinstance(source_payload.get("uuid"), str) + else None + ), + "description": ( + source_payload.get("description") + if isinstance(source_payload.get("description"), str) + else None + ), + "doi": ( + source_payload.get("doi") + if isinstance(source_payload.get("doi"), str) + else None + ), + "release_date": ( + source_payload.get("release_date") + if isinstance(source_payload.get("release_date"), str) + else None + ), "score": item.get("_score"), } +def _compact_dataset_result(item: Dict[str, Any]) -> Dict[str, Any]: + score_value = item.get("score") + score = score_value if isinstance(score_value, (int, float)) else None + compact: Dict[str, Any] = { + "title": _short_text(item.get("title", "Untitled"), 180), + "accession": item.get("accession") if isinstance(item.get("accession"), str) else "", + "url": item.get("url") if isinstance(item.get("url"), str) else None, + "doi": item.get("doi") if isinstance(item.get("doi"), str) else None, + "release_date": item.get("release_date") if isinstance(item.get("release_date"), str) else None, + "score": score, + } + return compact + + def _image_result_from_hit(item: Dict[str, Any]) -> Dict[str, Any]: - source_payload = item.get("_source") if isinstance(item.get("_source"), dict) else item + source_payload = ( + item.get("_source") if isinstance(item.get("_source"), dict) else item + ) file_pattern_payload = _metadata_value(source_payload, "file_pattern") - file_pattern = file_pattern_payload.get("file_pattern") if isinstance(file_pattern_payload, dict) else None + file_pattern = ( + file_pattern_payload.get("file_pattern") + if isinstance(file_pattern_payload, dict) + else None + ) - creation_process = source_payload.get("creation_process") if isinstance(source_payload.get("creation_process"), dict) else {} - acquisition_process = creation_process.get("acquisition_process") if isinstance(creation_process.get("acquisition_process"), list) else [] - first_acquisition = acquisition_process[0] if acquisition_process and isinstance(acquisition_process[0], dict) else {} - acquisition_title = first_acquisition.get("title") if isinstance(first_acquisition.get("title"), str) else None + creation_process = ( + source_payload.get("creation_process") + if isinstance(source_payload.get("creation_process"), dict) + else {} + ) + acquisition_process = ( + creation_process.get("acquisition_process") + if isinstance(creation_process.get("acquisition_process"), list) + else [] + ) + first_acquisition = ( + acquisition_process[0] + if acquisition_process and isinstance(acquisition_process[0], dict) + else {} + ) + acquisition_title = ( + first_acquisition.get("title") + if isinstance(first_acquisition.get("title"), str) + else None + ) accession = _first_nonempty_string( [ @@ -119,17 +189,22 @@ def _image_result_from_hit(item: Dict[str, Any]) -> Dict[str, Any]: source_payload.get("study_accession"), ] ) - image_id = _first_nonempty_string([source_payload.get("uuid"), item.get("_id")]) or "" - title = _first_nonempty_string( - [ - source_payload.get("title"), - source_payload.get("name"), - source_payload.get("label"), - file_pattern if isinstance(file_pattern, str) else None, - acquisition_title, - image_id, - ] - ) or "Untitled" + image_id = ( + _first_nonempty_string([source_payload.get("uuid"), item.get("_id")]) or "" + ) + title = ( + _first_nonempty_string( + [ + source_payload.get("title"), + source_payload.get("name"), + source_payload.get("label"), + file_pattern if isinstance(file_pattern, str) else None, + acquisition_title, + image_id, + ] + ) + or "Untitled" + ) return { "id": image_id, @@ -151,6 +226,100 @@ def _image_result_from_hit(item: Dict[str, Any]) -> Dict[str, Any]: } +def _compact_image_result(item: Dict[str, Any]) -> Dict[str, Any]: + score_value = item.get("score") + score = score_value if isinstance(score_value, (int, float)) else None + return { + "title": _short_text(item.get("title", "Untitled"), 180), + "id": item.get("id") if isinstance(item.get("id"), str) else "", + "accession": item.get("accession") if isinstance(item.get("accession"), str) else "", + "study_url": item.get("study_url") if isinstance(item.get("study_url"), str) else None, + "file_pattern": item.get("file_pattern") if isinstance(item.get("file_pattern"), str) else None, + "score": score, + } + + +def _format_dataset_assistant_summary(payload: Dict[str, Any], max_items: int = 5) -> str: + results = payload.get("results") + result_list = results if isinstance(results, list) else [] + if not result_list: + query = payload.get("query") if isinstance(payload.get("query"), str) else "" + return ( + f"No dataset results were found for query '{query}'. " + "The BioImage Archive beta index may be incomplete/intermittent." + ) + + lines = [f"Here are up to {max_items} BioImage Archive dataset matches:"] + for idx, entry in enumerate(result_list[:max_items], start=1): + if not isinstance(entry, dict): + continue + title = entry.get("title") if isinstance(entry.get("title"), str) else "Untitled" + accession = entry.get("accession") if isinstance(entry.get("accession"), str) else "" + url = entry.get("url") if isinstance(entry.get("url"), str) else None + score = entry.get("score") + score_part = f" (score {score:.2f})" if isinstance(score, (int, float)) else "" + if accession and url: + lines.append(f"{idx}. {title} [{accession}] - {url}{score_part}") + elif accession: + lines.append(f"{idx}. {title} [{accession}]{score_part}") + elif url: + lines.append(f"{idx}. {title} - {url}{score_part}") + else: + lines.append(f"{idx}. {title}{score_part}") + + total = payload.get("total") + if isinstance(total, int): + lines.append(f"(Total hits reported by API: {total})") + lines.append("Note: BioImage Archive beta search can be incomplete or intermittent.") + return "\n".join(lines) + + +def _normalize_search_payload( + kind: str, + query: str, + limit: int, + payload: Dict[str, Any], +) -> Dict[str, Any]: + safe_limit = max(1, int(limit)) + result_limit = min(max(1, safe_limit), 8) + + raw_results = payload.get("results") + result_items = raw_results if isinstance(raw_results, list) else [] + + compact_results: List[Dict[str, Any]] = [] + for entry in result_items[:result_limit]: + if not isinstance(entry, dict): + continue + if kind == "datasets": + compact_results.append(_compact_dataset_result(entry)) + else: + compact_results.append(_compact_image_result(entry)) + + total_value = payload.get("total") + total = total_value if isinstance(total_value, int) else len(compact_results) + + payload_url = payload.get("url") + if isinstance(payload_url, str) and payload_url.strip(): + url = payload_url + else: + base_url = BASE_SEARCH_URL if kind == "datasets" else BASE_IMAGE_SEARCH_URL + url = _build_url(base_url, query) + + normalized: Dict[str, Any] = { + "query": query, + "url": url, + "total": total, + "results": compact_results, + } + + if kind == "datasets": + normalized["assistant_summary"] = _format_dataset_assistant_summary( + normalized, max_items=min(5, safe_limit) + ) + + return normalized + + async def _search_via_proxy(kind: str, query: str, limit: int) -> Dict[str, Any] | None: if js is None: return None @@ -189,11 +358,12 @@ async def search_datasets(query: str, limit: int = 10) -> Dict[str, Any]: Returns: Dictionary with request URL, total count, and top results. """ - proxied = await _search_via_proxy("datasets", query, limit) + safe_limit = max(1, int(limit)) + proxied = await _search_via_proxy("datasets", query, safe_limit) if isinstance(proxied, dict): if "error" in proxied: raise RuntimeError(proxied["error"]) - return proxied + return _normalize_search_payload("datasets", query, safe_limit, proxied) url = _build_url(BASE_SEARCH_URL, query) async with httpx.AsyncClient(timeout=30.0) as client: @@ -204,16 +374,17 @@ async def search_datasets(query: str, limit: int = 10) -> Dict[str, Any]: hits, total = _extract_hits_and_total(payload) top_hits: List[Dict[str, Any]] = [] - for item in hits[: max(1, limit)]: + for item in hits[: max(1, safe_limit)]: if isinstance(item, dict): top_hits.append(_dataset_result_from_hit(item)) - return { + raw_payload = { "query": query, "url": url, "total": total, "results": top_hits, } + return _normalize_search_payload("datasets", query, safe_limit, raw_payload) async def search_images(query: str, limit: int = 10) -> Dict[str, Any]: @@ -227,11 +398,12 @@ async def search_images(query: str, limit: int = 10) -> Dict[str, Any]: Returns: Dictionary with request URL, total count, and top results. """ - proxied = await _search_via_proxy("images", query, limit) + safe_limit = max(1, int(limit)) + proxied = await _search_via_proxy("images", query, safe_limit) if isinstance(proxied, dict): if "error" in proxied: raise RuntimeError(proxied["error"]) - return proxied + return _normalize_search_payload("images", query, safe_limit, proxied) url = _build_url(BASE_IMAGE_SEARCH_URL, query) async with httpx.AsyncClient(timeout=30.0) as client: @@ -242,16 +414,17 @@ async def search_images(query: str, limit: int = 10) -> Dict[str, Any]: hits, total = _extract_hits_and_total(payload) top_hits: List[Dict[str, Any]] = [] - for item in hits[: max(1, limit)]: + for item in hits[: max(1, safe_limit)]: if isinstance(item, dict): top_hits.append(_image_result_from_hit(item)) - return { + raw_payload = { "query": query, "url": url, "total": total, "results": top_hits, } + return _normalize_search_payload("images", query, safe_limit, raw_payload) def explain_advanced_query_syntax() -> str: @@ -277,6 +450,40 @@ def explain_advanced_query_syntax() -> str: ) +def _sample_titles(items: List[Dict[str, Any]], max_items: int = 3) -> List[str]: + titles: List[str] = [] + for item in items[:max_items]: + title = item.get("title") + if isinstance(title, str) and title.strip(): + titles.append(title.strip()) + return titles + + +async def _probe_beta_index() -> List[str]: + probes = [ + ("datasets", "tumor", search_datasets), + ("datasets", "mouse", search_datasets), + ("datasets", "cancer", search_datasets), + ("images", "tumor", search_images), + ] + lines: List[str] = [] + for kind, query, fn in probes: + try: + payload = await fn(query, limit=3) + total = payload.get("total", 0) + total_int = total if isinstance(total, int) else 0 + results = payload.get("results") + top_results = results if isinstance(results, list) else [] + titles = _sample_titles(top_results) + lines.append(f"{kind}:{query} -> total={total_int}, sample_titles={titles}") + except Exception as exc: + lines.append(f"{kind}:{query} -> error={exc}") + return lines + + +_beta_probe_lines = await _probe_beta_index() + + print( """ You are the RI-SCALE BioImage Finder. @@ -287,9 +494,16 @@ def explain_advanced_query_syntax() -> str: - explain_advanced_query_syntax() Use tools first whenever a user asks for archive results. -- When querying based on the users' prompt you should generally make a very brief search query string. -- Use the search query format " OR [...]", before any other queries. -Then provide a concise human summary with links/accessions. -If one query returns no results, try one or two query rewrites before concluding. +- The beta index is limited/incomplete, so infer likely terms from startup probe output. +- When querying based on the user's prompt, start very briefly. +- Prefer OR-style brief queries first (for example: "mouse OR tumor"). +- If queries fail repeatedly or are empty, simplify to single-term fallbacks ("tumor", "mouse", "cancer"). +- Make at most two fallback calls, then provide a best-effort final answer and explicitly mention beta limitations. +- Tool outputs are intentionally compact and may include an assistant_summary field; use that summary when finalizing under timeout/fallback. +Then provide a concise human summary with links/accessions whenever available. """ ) + +print("Beta API startup probe (minimal filters):") +for _line in _beta_probe_lines: + print(f"- {_line}") diff --git a/scripts/test_chat_proxy.py b/scripts/test_chat_proxy.py index 97dbe9f6..efe40f5e 100644 --- a/scripts/test_chat_proxy.py +++ b/scripts/test_chat_proxy.py @@ -4,7 +4,9 @@ import json import os import traceback +from datetime import datetime, timezone +import httpx from hypha_rpc import connect_to_server @@ -57,20 +59,25 @@ def parse_args() -> argparse.Namespace: default="", ) parser.add_argument( - "--check-search", + "--check-request-url", action="store_true", - help="Also verify search_datasets/search_images tool bridge", + help="Probe proxy.resolve_url repeatedly through Hypha service", ) parser.add_argument( - "--search-query", - default="cancer", - help="Query used when --check-search is enabled", + "--request-url", + default="https://beta.bioimagearchive.org/search/search/fts?query=mouse%20OR%20tumor", + help="URL to probe when --check-request-url is enabled", ) parser.add_argument( - "--search-limit", + "--request-attempts", type=int, default=5, - help="Limit used when --check-search is enabled", + help="Number of repeated resolve_url probes", + ) + parser.add_argument( + "--compare-direct", + action="store_true", + help="Also perform direct HTTP probes for the same URL", ) return parser.parse_args() @@ -145,52 +152,80 @@ async def run_health_check(args: argparse.Namespace) -> int: print("✅ chat-proxy health check passed") - if args.check_search: - print("Checking BioImage Archive search bridge...") - - if not hasattr(proxy, "search_datasets"): - print("❌ proxy is missing search_datasets") - return 1 - if not hasattr(proxy, "search_images"): - print("❌ proxy is missing search_images") + if args.check_request_url: + if not hasattr(proxy, "resolve_url"): + print("❌ proxy is missing resolve_url") return 1 - try: - datasets = await asyncio.wait_for( - proxy.search_datasets(args.search_query, int(args.search_limit)), - timeout=float(args.timeout), - ) - images = await asyncio.wait_for( - proxy.search_images(args.search_query, int(args.search_limit)), - timeout=float(args.timeout), - ) - except asyncio.TimeoutError: - print(f"❌ search bridge timed out after {args.timeout}s") - return 1 - except Exception as exp: - print(f"❌ search bridge call failed: {exp}") - traceback.print_exc() - return 1 + print("Probing resolve_url through Hypha app...") + proxy_statuses: dict[str, int] = {} + for attempt in range(1, max(1, int(args.request_attempts)) + 1): + ts = datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ") + try: + payload = await asyncio.wait_for( + proxy.resolve_url( + url=args.request_url, + method="GET", + headers={"Accept": "application/json"}, + timeout=30, + ), + timeout=float(args.timeout), + ) + except asyncio.TimeoutError: + print(f"{ts} | proxy | attempt={attempt} | timeout") + proxy_statuses["timeout"] = proxy_statuses.get("timeout", 0) + 1 + continue + except Exception as exp: + print(f"{ts} | proxy | attempt={attempt} | exception={exp}") + proxy_statuses["exception"] = proxy_statuses.get("exception", 0) + 1 + continue - for label, payload in (("datasets", datasets), ("images", images)): if not isinstance(payload, dict): print( - f"❌ {label} search returned non-dict payload: {type(payload)}" + f"{ts} | proxy | attempt={attempt} | unexpected_payload_type={type(payload)}" ) - return 1 - if ( - "query" not in payload - or "results" not in payload - or "total" not in payload - ): - print(f"❌ {label} search payload missing expected keys") - print(json.dumps(payload, indent=2)) - return 1 - if not isinstance(payload.get("results"), list): - print(f"❌ {label} search results is not a list") - return 1 - - print("✅ search bridge health check passed") + proxy_statuses["unexpected_payload"] = ( + proxy_statuses.get("unexpected_payload", 0) + 1 + ) + continue + + status_code = payload.get("status_code") + code_key = str(status_code) + proxy_statuses[code_key] = proxy_statuses.get(code_key, 0) + 1 + error_text = payload.get("error") or "" + print( + f"{ts} | proxy | attempt={attempt} | status_code={status_code} | ok={payload.get('ok')} | error={error_text}" + ) + + print("Proxy resolve_url status summary:") + print(json.dumps(proxy_statuses, indent=2, sort_keys=True)) + + if args.compare_direct: + print("Probing direct endpoint from same client host...") + direct_statuses: dict[str, int] = {} + for attempt in range(1, max(1, int(args.request_attempts)) + 1): + ts = datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ") + try: + async with httpx.AsyncClient( + timeout=30.0, follow_redirects=True + ) as client: + response = await client.get( + args.request_url, + headers={"Accept": "application/json"}, + ) + key = str(response.status_code) + direct_statuses[key] = direct_statuses.get(key, 0) + 1 + print( + f"{ts} | direct | attempt={attempt} | status_code={response.status_code}" + ) + except Exception as exp: + direct_statuses["exception"] = ( + direct_statuses.get("exception", 0) + 1 + ) + print(f"{ts} | direct | attempt={attempt} | exception={exp}") + + print("Direct request status summary:") + print(json.dumps(direct_statuses, indent=2, sort_keys=True)) return 0 except asyncio.TimeoutError: diff --git a/scripts/update_agent_startup_script.py b/scripts/update_agent_startup_script.py index 22e06336..aec129f6 100644 --- a/scripts/update_agent_startup_script.py +++ b/scripts/update_agent_startup_script.py @@ -16,7 +16,9 @@ def _extract_manifest(payload: Dict[str, Any]) -> Dict[str, Any]: async def _run(args: argparse.Namespace) -> None: token = args.token if not token: - raise ValueError("A token is required. Pass --token or set HYPHA_TOKEN in the environment.") + raise ValueError( + "A token is required. Pass --token or set HYPHA_TOKEN in the environment." + ) startup_script_path = Path(args.startup_script).resolve() if not startup_script_path.exists(): @@ -57,7 +59,9 @@ async def _run(args: argparse.Namespace) -> None: ) if persisted_script != startup_script: - raise RuntimeError("Verification failed: persisted startup_script content does not match source file.") + raise RuntimeError( + "Verification failed: persisted startup_script content does not match source file." + ) print("Startup script updated and verified.") print(f"artifact_id={args.artifact_id}") @@ -65,7 +69,9 @@ async def _run(args: argparse.Namespace) -> None: async def main() -> None: - parser = argparse.ArgumentParser(description="Update startup_script for a Hypha agent artifact") + parser = argparse.ArgumentParser( + description="Update startup_script for a Hypha agent artifact" + ) parser.add_argument( "--artifact-id", default="hypha-agents/grammatical-deduction-bury-enormously", diff --git a/src/components/Docs.tsx b/src/components/Docs.tsx index 3929785c..0a815ddd 100644 --- a/src/components/Docs.tsx +++ b/src/components/Docs.tsx @@ -249,6 +249,14 @@ const Docs: React.FC = () => { https://beta.bioimagearchive.org/search/search/fts/image: Search images + +
+
Architecture Principle
+

+ Agent-specific behavior belongs in the agent startup script, not in the generic chat runner. + For BioImage Finder, compact search payload shaping and fallback dataset summary formatting are implemented in the startup script to keep the frontend agent-agnostic. +

+
diff --git a/src/pages/AgentPage.tsx b/src/pages/AgentPage.tsx index 1e717f8f..69751f57 100644 --- a/src/pages/AgentPage.tsx +++ b/src/pages/AgentPage.tsx @@ -1047,11 +1047,31 @@ except Exception as e: # Define the proxy function for compatibility with agents expecting js.hypha_chat_proxy def _is_timeout_payload(payload): - if isinstance(payload, str): - return "timed out" in payload.lower() or "timeout" in payload.lower() + timeout_tokens = [ + "request timed out", + "timed out", + "request timeout", + "gateway timeout", + "deadline exceeded", + "upstream timeout", + "bridge-timeout", + ] + timeout_codes = {"timeout", "request_timeout", "gateway_timeout", "upstream_timeout", "bridge-timeout"} if isinstance(payload, dict): + code = payload.get("code") + if isinstance(code, str) and code.strip().lower() in timeout_codes: + return True + status = payload.get("status") or payload.get("status_code") + if isinstance(status, int) and status in (408, 504): + return True err = payload.get("error") or payload.get("message") - return isinstance(err, str) and ("timed out" in err.lower() or "timeout" in err.lower()) + if isinstance(err, str): + lowered = err.lower() + return any(token in lowered for token in timeout_tokens) + return False + if isinstance(payload, str): + lowered = payload.lower() + return any(token in lowered for token in timeout_tokens) return False def _extract_timeout_seconds(timeout_value, fallback=30.0): @@ -1138,20 +1158,20 @@ async def _resolve_proxy_service_for_utilities(): async def _proxy_request_url_via_service(url, method="GET", headers=None, timeout=30.0): try: proxy = await _resolve_proxy_service_for_utilities() - if not hasattr(proxy, 'request_url'): + if not hasattr(proxy, 'resolve_url'): return { "ok": False, - "error": "chat-proxy service is missing request_url; deploy updated chat-proxy app", + "error": "chat-proxy service is missing resolve_url; deploy updated chat-proxy app", "status_code": 502, "url": str(url), } payload = await asyncio.wait_for( - proxy.request_url(url=url, method=method, headers=headers or {}, timeout=float(timeout)), + proxy.resolve_url(url=url, method=method, headers=headers or {}, timeout=float(timeout)), timeout=max(5.0, float(timeout) + 5.0) ) return payload if isinstance(payload, dict) else { "ok": False, - "error": f"Unexpected request_url payload type: {type(payload)}", + "error": f"Unexpected resolve_url payload type: {type(payload)}", "status_code": 500, "url": str(url), "text": str(payload), @@ -1160,7 +1180,7 @@ async def _proxy_request_url_via_service(url, method="GET", headers=None, timeou return { "ok": False, "error": str(exp), - "status_code": 500, + "status_code": 502, "url": str(url), } @@ -1283,9 +1303,12 @@ async def hypha_chat_proxy(messages_json, tools_json, tool_choice_json, model): except asyncio.TimeoutError: return json.dumps({"error": "bridge-timeout: proxy call exceeded ${Math.floor(CHAT_PROXY_REQUEST_TIMEOUT_MS / 1000)}s"}) except BaseException as e: + if isinstance(e, asyncio.CancelledError): + return json.dumps({"error": "bridge-timeout: request cancelled while awaiting proxy response"}) print(f"DEBUG: Exception in hypha_chat_proxy bridge: {e}") traceback.print_exc() - return json.dumps({"error": f"bridge-error: {str(e)}"}) + error_text = str(e).strip() or e.__class__.__name__ + return json.dumps({"error": f"bridge-error: {error_text}"}) print("DEBUG: hypha_chat_proxy bridge ready") _install_httpx_proxy_patch() @@ -2049,6 +2072,8 @@ async def _chat_wrapper(): successful_tool_calls = 0 successful_tool_results = [] timeout_finalize_requested = False + dataset_query_failures = 0 + single_term_hint_added = False def _short_text(value, max_len=400): text = str(value) @@ -2056,10 +2081,40 @@ async def _chat_wrapper(): return text return text[:max_len] + "..." + def _try_parse_dict(tool_output): + if isinstance(tool_output, dict): + return tool_output + if not isinstance(tool_output, str): + return None + text = tool_output.strip() + if not text: + return None + try: + parsed = json.loads(text) + if isinstance(parsed, dict): + return parsed + except Exception: + pass + try: + import ast + parsed = ast.literal_eval(text) + if isinstance(parsed, dict): + return parsed + except Exception: + return None + return None + def _summarize_tool_results(): if not successful_tool_results: return None + for item in successful_tool_results: + parsed_output = _try_parse_dict(item.get('output')) + if isinstance(parsed_output, dict): + assistant_summary = parsed_output.get('assistant_summary') + if isinstance(assistant_summary, str) and assistant_summary.strip(): + return assistant_summary + lines = ["I executed the available tool(s) and collected these results:"] for index, item in enumerate(successful_tool_results[:5], start=1): tool_name = item.get('name') or 'tool' @@ -2072,16 +2127,111 @@ async def _chat_wrapper(): return "\\n".join(lines) def _is_timeout_error_payload(payload): - if isinstance(payload, str): - lowered = payload.lower() - return "timed out" in lowered or "timeout" in lowered if isinstance(payload, dict): + timeout_codes = {"timeout", "request_timeout", "gateway_timeout", "upstream_timeout", "bridge-timeout"} + code = payload.get("code") + if isinstance(code, str) and code.strip().lower() in timeout_codes: + return True + status = payload.get("status") or payload.get("status_code") + if isinstance(status, int) and status in (408, 504): + return True maybe_error = payload.get("error") or payload.get("message") if isinstance(maybe_error, str): lowered = maybe_error.lower() - return "timed out" in lowered or "timeout" in lowered + return any(token in lowered for token in [ + "request timed out", + "timed out", + "request timeout", + "gateway timeout", + "deadline exceeded", + "upstream timeout", + "bridge-timeout", + ]) + return False + if isinstance(payload, str): + lowered = payload.lower() + return any(token in lowered for token in [ + "request timed out", + "timed out", + "request timeout", + "gateway timeout", + "deadline exceeded", + "upstream timeout", + "bridge-timeout", + ]) return False + def _ensure_latest_tool_call_responses(messages_payload): + if not isinstance(messages_payload, list) or not messages_payload: + return + + assistant_index = None + expected_ids = [] + for idx in range(len(messages_payload) - 1, -1, -1): + message = messages_payload[idx] + if not isinstance(message, dict) or message.get("role") != "assistant": + continue + tool_calls_in_message = message.get("tool_calls") + if not isinstance(tool_calls_in_message, list) or not tool_calls_in_message: + continue + + ids = [] + for tool_call in tool_calls_in_message: + if not isinstance(tool_call, dict): + continue + tool_call_id = tool_call.get("id") + if isinstance(tool_call_id, str) and tool_call_id.strip(): + ids.append(tool_call_id) + + if ids: + assistant_index = idx + expected_ids = ids + break + + if assistant_index is None or not expected_ids: + return + + seen_ids = set() + tool_scan_index = assistant_index + 1 + while tool_scan_index < len(messages_payload): + candidate = messages_payload[tool_scan_index] + if not isinstance(candidate, dict): + tool_scan_index += 1 + continue + if candidate.get("role") != "tool": + break + candidate_id = candidate.get("tool_call_id") + if isinstance(candidate_id, str): + seen_ids.add(candidate_id) + tool_scan_index += 1 + + missing_ids = [tool_call_id for tool_call_id in expected_ids if tool_call_id not in seen_ids] + for missing_id in missing_ids: + messages_payload.insert(tool_scan_index, { + "tool_call_id": missing_id, + "role": "tool", + "name": "tool", + "content": "Error: tool call response missing due runtime interruption.", + }) + tool_scan_index += 1 + + async def _call_follow_up(messages_payload, tools_payload, tool_choice_payload): + _ensure_latest_tool_call_responses(messages_payload) + remaining_seconds = soft_deadline - asyncio.get_event_loop().time() + timeout_seconds = min(35.0, max(5.0, remaining_seconds + 2.0)) + try: + return await asyncio.wait_for( + hypha_chat_proxy( + json.dumps(messages_payload), + json.dumps(tools_payload) if tools_payload else None, + json.dumps(tool_choice_payload) if tools_payload else None, + '${chatModel}' + ), + timeout=timeout_seconds + ) + except asyncio.TimeoutError: + return json.dumps({"error": "request_timeout", "code": "request_timeout", "status": 408}) + while True: tool_calls = response_message.get('tool_calls') content = response_message.get('content') @@ -2095,6 +2245,8 @@ async def _chat_wrapper(): return messages.append(response_message) + tool_errors_this_turn = 0 + tool_success_this_turn = 0 for tool_call in tool_calls: total_tool_calls += 1 @@ -2134,6 +2286,7 @@ async def _chat_wrapper(): function_response_text = str(function_response) successful_tool_calls += 1 + tool_success_this_turn += 1 successful_tool_results.append({ "name": function_name, "output": function_response_text, @@ -2147,6 +2300,15 @@ async def _chat_wrapper(): }) except Exception as e: error_text = str(e) + tool_errors_this_turn += 1 + if function_name == "search_datasets": + dataset_query_failures += 1 + if dataset_query_failures >= 2 and not single_term_hint_added: + messages.append({ + "role": "system", + "content": "Dataset queries have repeatedly failed. Simplify now to single-term fallback queries likely present in the beta index (for example: tumor, mouse, cancer). Make at most two fallback calls, then provide the best possible final answer and explicitly mention beta index limitations.", + }) + single_term_hint_added = True successful_tool_results.append({ "name": function_name, "output": f"Error: {error_text}", @@ -2158,6 +2320,7 @@ async def _chat_wrapper(): "content": f"Error: {error_text}", }) else: + tool_errors_this_turn += 1 messages.append({ "tool_call_id": tool_call_id, "role": "tool", @@ -2165,6 +2328,14 @@ async def _chat_wrapper(): "content": f"Error: Tool '{function_name}' is not available.", }) + if dataset_query_failures >= 2 and tool_success_this_turn == 0 and tool_errors_this_turn > 0: + summary_text = _summarize_tool_results() + if isinstance(summary_text, str) and summary_text: + send_response({ + "text": f"{summary_text}\\n\\nI could not find exact matches for the original request. The BioImage Archive API is currently in beta and appears limited/intermittent. I used simplified fallback queries and returned the best available results/errors.", + }) + return + if asyncio.get_event_loop().time() >= soft_deadline and not timeout_finalize_requested: timeout_finalize_requested = True messages.append({ @@ -2176,12 +2347,7 @@ async def _chat_wrapper(): next_tools_json = json.dumps(tools) if tools else None next_tool_choice = "none" if timeout_finalize_requested else "auto" next_tool_choice_json = json.dumps(next_tool_choice) if tools else None - next_result_json = await hypha_chat_proxy( - json.dumps(messages), - next_tools_json, - next_tool_choice_json, - '${chatModel}' - ) + next_result_json = await _call_follow_up(messages, tools, next_tool_choice) try: next_result = json.loads(next_result_json) except Exception as parse_err: @@ -2200,12 +2366,7 @@ async def _chat_wrapper(): "role": "system", "content": "The previous model call timed out. Provide the best possible final answer now using the tool outputs already collected. Do not call additional tools.", }) - forced_result_json = await hypha_chat_proxy( - json.dumps(messages), - json.dumps(tools) if tools else None, - json.dumps("none") if tools else None, - '${chatModel}' - ) + forced_result_json = await _call_follow_up(messages, tools, "none") try: forced_result = json.loads(forced_result_json) if isinstance(forced_result, dict) and "error" in forced_result: @@ -2237,7 +2398,18 @@ await _chat_wrapper() if (executeCode) { await executeCode(code, { onOutput: (log) => { + const isBenignPyodideToPyNoise = (text: string | undefined) => { + if (!text) return false; + const normalized = text.toLowerCase(); + return normalized.includes('pyodide_websocket.py') + && normalized.includes("'str' object has no attribute 'to_py'"); + }; + if (log.type === 'error') { + if (isBenignPyodideToPyNoise(log.content)) { + updateAgentProgress('Ignoring known Pyodide websocket warning...', log.content || ''); + return; + } safeReject(new Error(log.content || 'Kernel execution error')); return; } @@ -2260,6 +2432,10 @@ await _chat_wrapper() if (log.type === 'stderr' && log.content) { const stderr = log.content.trim(); if (stderr) { + if (isBenignPyodideToPyNoise(stderr)) { + updateAgentProgress('Ignoring known Pyodide websocket warning...'); + return; + } updateAgentProgress('Agent emitted runtime logs...', stderr); } } From a47a630175fbf415ec5f62eb01d11c0ea53752dc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Hugo=20Dettner=20K=C3=A4llander?= Date: Mon, 23 Feb 2026 23:04:21 +0100 Subject: [PATCH 10/18] Prefer branch-specific chat proxy app over chat-proxy-dev fallback --- src/pages/AgentPage.tsx | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/src/pages/AgentPage.tsx b/src/pages/AgentPage.tsx index 69751f57..f1d1ebe6 100644 --- a/src/pages/AgentPage.tsx +++ b/src/pages/AgentPage.tsx @@ -61,6 +61,7 @@ const CHAT_MODEL_OPTIONS: Array<{ value: string; label: string }> = [ const CHAT_MODEL_IDS = new Set(CHAT_MODEL_OPTIONS.map(option => option.value)); const DEFAULT_DEV_CHAT_PROXY_APP_ID = 'chat-proxy-dev'; +const BRANCH_SPECIFIC_CHAT_PROXY_APP_ID = 'chat-proxy-dev-fix-bia-hit-extraction-and-branch-proxy'; const PRODUCTION_CHAT_PROXY_APP_ID = 'chat-proxy'; const MAX_CHAT_PROXY_APP_ID_LENGTH = 63; const CHAT_PROXY_REQUEST_TIMEOUT_MS = 900_000; @@ -139,10 +140,14 @@ const getChatProxyServiceIds = (): string[] => { ]).map(normalizeBranchRefName); const branchAppIds = branchNameCandidates.map((branchName) => makeDevAppId(branchName)); + const nonGenericConfiguredAppId = configuredAppId && configuredAppId !== DEFAULT_DEV_CHAT_PROXY_APP_ID + ? configuredAppId + : ''; const appIdCandidates = uniqueNonEmptyValues([ explicitBranchProxyAppId, - configuredAppId, ...branchAppIds, + BRANCH_SPECIFIC_CHAT_PROXY_APP_ID, + nonGenericConfiguredAppId, DEFAULT_DEV_CHAT_PROXY_APP_ID, ]); From dd2dd53bce38dab20d5f6ad236f09e902b1f9172 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Hugo=20Dettner=20K=C3=A4llander?= Date: Mon, 23 Feb 2026 23:11:37 +0100 Subject: [PATCH 11/18] BioImage startup: stop early after enough dataset hits --- docs/bioimage-finder-startup-script.py | 1 + scripts/agent_startup_scripts/bioimage_finder_startup_script.py | 1 + 2 files changed, 2 insertions(+) diff --git a/docs/bioimage-finder-startup-script.py b/docs/bioimage-finder-startup-script.py index b68423ae..0081d0c6 100644 --- a/docs/bioimage-finder-startup-script.py +++ b/docs/bioimage-finder-startup-script.py @@ -499,6 +499,7 @@ async def _probe_beta_index() -> List[str]: - Prefer OR-style brief queries first (for example: "mouse OR tumor"). - If queries fail repeatedly or are empty, simplify to single-term fallbacks ("tumor", "mouse", "cancer"). - Make at most two fallback calls, then provide a best-effort final answer and explicitly mention beta limitations. +- If any dataset query already returns at least the requested number of results, stop calling tools and answer immediately. - Tool outputs are intentionally compact and may include an assistant_summary field; use that summary when finalizing under timeout/fallback. Then provide a concise human summary with links/accessions whenever available. """ diff --git a/scripts/agent_startup_scripts/bioimage_finder_startup_script.py b/scripts/agent_startup_scripts/bioimage_finder_startup_script.py index b68423ae..0081d0c6 100644 --- a/scripts/agent_startup_scripts/bioimage_finder_startup_script.py +++ b/scripts/agent_startup_scripts/bioimage_finder_startup_script.py @@ -499,6 +499,7 @@ async def _probe_beta_index() -> List[str]: - Prefer OR-style brief queries first (for example: "mouse OR tumor"). - If queries fail repeatedly or are empty, simplify to single-term fallbacks ("tumor", "mouse", "cancer"). - Make at most two fallback calls, then provide a best-effort final answer and explicitly mention beta limitations. +- If any dataset query already returns at least the requested number of results, stop calling tools and answer immediately. - Tool outputs are intentionally compact and may include an assistant_summary field; use that summary when finalizing under timeout/fallback. Then provide a concise human summary with links/accessions whenever available. """ From 6981eaa47079a4fe4e02f05d6cafc45cdb52b781 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Hugo=20Dettner=20K=C3=A4llander?= Date: Mon, 23 Feb 2026 23:46:41 +0100 Subject: [PATCH 12/18] Hide backend timeout errors; improve BioImage AND-query fallback --- docs/bioimage-finder-startup-script.py | 50 +++++++++++++++---- .../bioimage_finder_startup_script.py | 50 +++++++++++++++---- src/pages/AgentPage.tsx | 35 ++++++++++--- 3 files changed, 108 insertions(+), 27 deletions(-) diff --git a/docs/bioimage-finder-startup-script.py b/docs/bioimage-finder-startup-script.py index 0081d0c6..98739f56 100644 --- a/docs/bioimage-finder-startup-script.py +++ b/docs/bioimage-finder-startup-script.py @@ -1,4 +1,5 @@ import json +import re from typing import Any, Dict, List from urllib.parse import quote @@ -347,18 +348,20 @@ async def _search_via_proxy(kind: str, query: str, limit: int) -> Dict[str, Any] return {"error": f"Proxy search failed: {exp}"} -async def search_datasets(query: str, limit: int = 10) -> Dict[str, Any]: - """ - Search BioImage Archive datasets by full-text query. +def _fallback_terms_from_query(query: str) -> List[str]: + terms: List[str] = [] + for part in re.split(r"\bAND\b|\bOR\b", query, flags=re.IGNORECASE): + candidate = part.strip().strip('"\'()[]{}') + if len(candidate) < 2: + continue + if candidate.lower() in {"and", "or", "not"}: + continue + if candidate not in terms: + terms.append(candidate) + return terms - Args: - query: User search text, supports boolean operators (AND/OR/NOT), quotes, wildcards. - limit: Maximum number of hits to return in the summarized output. - Returns: - Dictionary with request URL, total count, and top results. - """ - safe_limit = max(1, int(limit)) +async def _search_datasets_once(query: str, safe_limit: int) -> Dict[str, Any]: proxied = await _search_via_proxy("datasets", query, safe_limit) if isinstance(proxied, dict): if "error" in proxied: @@ -387,6 +390,32 @@ async def search_datasets(query: str, limit: int = 10) -> Dict[str, Any]: return _normalize_search_payload("datasets", query, safe_limit, raw_payload) +async def search_datasets(query: str, limit: int = 10) -> Dict[str, Any]: + """ + Search BioImage Archive datasets by full-text query. + + Args: + query: User search text, supports boolean operators (AND/OR/NOT), quotes, wildcards. + limit: Maximum number of hits to return in the summarized output. + + Returns: + Dictionary with request URL, total count, and top results. + """ + safe_limit = max(1, int(limit)) + primary_result = await _search_datasets_once(query, safe_limit) + if isinstance(primary_result.get("total"), int) and primary_result.get("total", 0) > 0: + return primary_result + + if " and " in query.lower(): + for term in _fallback_terms_from_query(query)[:2]: + fallback_result = await _search_datasets_once(term, safe_limit) + if isinstance(fallback_result.get("total"), int) and fallback_result.get("total", 0) > 0: + fallback_result["fallback_from_query"] = query + return fallback_result + + return primary_result + + async def search_images(query: str, limit: int = 10) -> Dict[str, Any]: """ Search BioImage Archive images endpoint by full-text query. @@ -497,6 +526,7 @@ async def _probe_beta_index() -> List[str]: - The beta index is limited/incomplete, so infer likely terms from startup probe output. - When querying based on the user's prompt, start very briefly. - Prefer OR-style brief queries first (for example: "mouse OR tumor"). +- If an OR query returns no dataset results, do not switch to AND. Immediately try single-term fallbacks. - If queries fail repeatedly or are empty, simplify to single-term fallbacks ("tumor", "mouse", "cancer"). - Make at most two fallback calls, then provide a best-effort final answer and explicitly mention beta limitations. - If any dataset query already returns at least the requested number of results, stop calling tools and answer immediately. diff --git a/scripts/agent_startup_scripts/bioimage_finder_startup_script.py b/scripts/agent_startup_scripts/bioimage_finder_startup_script.py index 0081d0c6..98739f56 100644 --- a/scripts/agent_startup_scripts/bioimage_finder_startup_script.py +++ b/scripts/agent_startup_scripts/bioimage_finder_startup_script.py @@ -1,4 +1,5 @@ import json +import re from typing import Any, Dict, List from urllib.parse import quote @@ -347,18 +348,20 @@ async def _search_via_proxy(kind: str, query: str, limit: int) -> Dict[str, Any] return {"error": f"Proxy search failed: {exp}"} -async def search_datasets(query: str, limit: int = 10) -> Dict[str, Any]: - """ - Search BioImage Archive datasets by full-text query. +def _fallback_terms_from_query(query: str) -> List[str]: + terms: List[str] = [] + for part in re.split(r"\bAND\b|\bOR\b", query, flags=re.IGNORECASE): + candidate = part.strip().strip('"\'()[]{}') + if len(candidate) < 2: + continue + if candidate.lower() in {"and", "or", "not"}: + continue + if candidate not in terms: + terms.append(candidate) + return terms - Args: - query: User search text, supports boolean operators (AND/OR/NOT), quotes, wildcards. - limit: Maximum number of hits to return in the summarized output. - Returns: - Dictionary with request URL, total count, and top results. - """ - safe_limit = max(1, int(limit)) +async def _search_datasets_once(query: str, safe_limit: int) -> Dict[str, Any]: proxied = await _search_via_proxy("datasets", query, safe_limit) if isinstance(proxied, dict): if "error" in proxied: @@ -387,6 +390,32 @@ async def search_datasets(query: str, limit: int = 10) -> Dict[str, Any]: return _normalize_search_payload("datasets", query, safe_limit, raw_payload) +async def search_datasets(query: str, limit: int = 10) -> Dict[str, Any]: + """ + Search BioImage Archive datasets by full-text query. + + Args: + query: User search text, supports boolean operators (AND/OR/NOT), quotes, wildcards. + limit: Maximum number of hits to return in the summarized output. + + Returns: + Dictionary with request URL, total count, and top results. + """ + safe_limit = max(1, int(limit)) + primary_result = await _search_datasets_once(query, safe_limit) + if isinstance(primary_result.get("total"), int) and primary_result.get("total", 0) > 0: + return primary_result + + if " and " in query.lower(): + for term in _fallback_terms_from_query(query)[:2]: + fallback_result = await _search_datasets_once(term, safe_limit) + if isinstance(fallback_result.get("total"), int) and fallback_result.get("total", 0) > 0: + fallback_result["fallback_from_query"] = query + return fallback_result + + return primary_result + + async def search_images(query: str, limit: int = 10) -> Dict[str, Any]: """ Search BioImage Archive images endpoint by full-text query. @@ -497,6 +526,7 @@ async def _probe_beta_index() -> List[str]: - The beta index is limited/incomplete, so infer likely terms from startup probe output. - When querying based on the user's prompt, start very briefly. - Prefer OR-style brief queries first (for example: "mouse OR tumor"). +- If an OR query returns no dataset results, do not switch to AND. Immediately try single-term fallbacks. - If queries fail repeatedly or are empty, simplify to single-term fallbacks ("tumor", "mouse", "cancer"). - Make at most two fallback calls, then provide a best-effort final answer and explicitly mention beta limitations. - If any dataset query already returns at least the requested number of results, stop calling tools and answer immediately. diff --git a/src/pages/AgentPage.tsx b/src/pages/AgentPage.tsx index f1d1ebe6..4b232884 100644 --- a/src/pages/AgentPage.tsx +++ b/src/pages/AgentPage.tsx @@ -69,6 +69,7 @@ const CHAT_PROXY_RESOLVE_TIMEOUT_MS = 60_000; const CHAT_PROXY_COMPLETION_TIMEOUT_MS = 900_000; const AGENT_TOOL_EXECUTION_LIMIT = 50; const AGENT_ITERATION_SOFT_TIMEOUT_MS = 90_000; +const AGENT_ITERATION_HARD_TIMEOUT_MS = 120_000; const slugifyBranchName = (branchName: string): string => { const normalized = branchName @@ -2056,11 +2057,11 @@ async def _chat_wrapper(): try: result = json.loads(result_json) except Exception as parse_err: - send_response({"text": f"Error from proxy: Invalid JSON response ({parse_err})"}) + send_response({"text": "I’m having trouble reading the backend response right now. Please try again."}) return if isinstance(result, dict) and "error" in result: - send_response({"text": f"Error from proxy: {result['error']}"}) + send_response({"text": "I’m having trouble reaching the chat backend right now. Please try again in a moment."}) return choice = result['choices'][0] @@ -2070,7 +2071,9 @@ async def _chat_wrapper(): max_turns = ${AGENT_TOOL_EXECUTION_LIMIT} soft_timeout_ms = ${AGENT_ITERATION_SOFT_TIMEOUT_MS} + hard_timeout_ms = ${AGENT_ITERATION_HARD_TIMEOUT_MS} soft_deadline = asyncio.get_event_loop().time() + (soft_timeout_ms / 1000.0) + hard_deadline = asyncio.get_event_loop().time() + (hard_timeout_ms / 1000.0) turns = 0 tool_result_cache = {} total_tool_calls = 0 @@ -2238,6 +2241,16 @@ async def _chat_wrapper(): return json.dumps({"error": "request_timeout", "code": "request_timeout", "status": 408}) while True: + if asyncio.get_event_loop().time() >= hard_deadline: + summary_text = _summarize_tool_results() + if isinstance(summary_text, str) and summary_text: + send_response({ + "text": f"{summary_text}\\n\\nI’m returning the best results gathered so far.", + }) + else: + send_response({"text": "I’m taking longer than expected, so I’m stopping here. Please try again."}) + return + tool_calls = response_message.get('tool_calls') content = response_message.get('content') @@ -2348,6 +2361,14 @@ async def _chat_wrapper(): "content": f"You have been working for about {int(soft_timeout_ms / 1000)} seconds. Provide the best possible final answer now using the tool results and errors already collected. Do not call additional tools unless absolutely necessary.", }) + if timeout_finalize_requested and successful_tool_results and turns >= 3: + summary_text = _summarize_tool_results() + if isinstance(summary_text, str) and summary_text: + send_response({ + "text": f"{summary_text}\\n\\nI’m returning the best results gathered so far.", + }) + return + turns += 1 next_tools_json = json.dumps(tools) if tools else None next_tool_choice = "none" if timeout_finalize_requested else "auto" @@ -2359,9 +2380,9 @@ async def _chat_wrapper(): if successful_tool_results: summary_text = _summarize_tool_results() if isinstance(summary_text, str) and summary_text: - send_response({"text": f"{summary_text}\\n\\nI’m returning the best results gathered so far because a follow-up model response could not be parsed ({parse_err})."}) + send_response({"text": f"{summary_text}\\n\\nI’m returning the best results gathered so far due to a temporary backend issue."}) return - send_response({"text": f"Error from proxy: Invalid JSON response ({parse_err})"}) + send_response({"text": "I’m having trouble finishing this request right now. Please try again."}) return if isinstance(next_result, dict) and "error" in next_result: @@ -2387,16 +2408,16 @@ async def _chat_wrapper(): if successful_tool_results: summary_text = _summarize_tool_results() if isinstance(summary_text, str) and summary_text: - send_response({"text": f"{summary_text}\\n\\nI’m returning the best results gathered so far because the follow-up model call failed: {next_result['error']}"}) + send_response({"text": f"{summary_text}\\n\\nI’m returning the best results gathered so far due to a temporary backend issue."}) return - send_response({"text": f"Error from proxy: {next_result['error']}"}) + send_response({"text": "I’m having trouble completing this request right now. Please try again in a moment."}) return response_message = next_result['choices'][0]['message'] except Exception as e: traceback.print_exc() - send_response({"text": f"Error executing chat: {str(e)}"}) + send_response({"text": "I hit a temporary runtime issue while preparing the response. Please try again."}) await _chat_wrapper() `; From 20a31a54add33a2a0af15e39ccf5aa98a5ebae86 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Hugo=20Dettner=20K=C3=A4llander?= Date: Tue, 24 Feb 2026 16:02:07 +0100 Subject: [PATCH 13/18] Improve BioImage finder quality, docs, and note styling --- docs/README.md | 23 ++ ...chive-search-incident-report-2026-02-23.md | 86 +++++++ docs/bioimage-finder-startup-script.py | 220 ++++++++++++++++- e2e/agent-chat-mouse-tumor-regression.spec.ts | 42 ++++ .../bioimage_finder_startup_script.py | 220 ++++++++++++++++- src/pages/AgentPage.tsx | 223 ++++++++++++++++-- 6 files changed, 769 insertions(+), 45 deletions(-) create mode 100644 docs/bioimage-archive-search-incident-report-2026-02-23.md diff --git a/docs/README.md b/docs/README.md index ba75bf7a..c5c220af 100644 --- a/docs/README.md +++ b/docs/README.md @@ -107,6 +107,29 @@ Important notes: - It can still happen on deployed domains unless that origin is explicitly allowed by the target server. - For reliability, route archive/network fetches through a backend proxy service where possible. +### BioImage Finder Result Quality + +For BioImage Archive dataset requests, the BioImage Finder startup script applies a relevance strategy designed for noisy/intermittent beta-index results: + +1. Build a brief OR-style query first (for example: `mouse OR tumor`). +2. If primary query quality is weak or empty, run fallback single-term queries (up to four terms). +3. Merge unique results from fallback queries and rerank by request-term relevance. +4. Return the top compact list with accessions/links and a clear beta-index limitation note. + +Implementation details live in: + +- `scripts/agent_startup_scripts/bioimage_finder_startup_script.py` +- `docs/bioimage-finder-startup-script.py` + +The startup script includes: + +- query-term extraction with stopword filtering, +- relevance scoring over title/description/accession, +- duplicate-safe result merging across fallback terms, +- assistant summaries optimized for concise user-facing answers. + +To validate quality behavior quickly, run the startup script checks against real API responses and inspect generated summaries for mixed-term prompts (for example, `mouse tumor cancer`). + ### Kernel Logs and Debug Report Agent chat includes a **Kernel Logs** panel for low-level diagnostics. diff --git a/docs/bioimage-archive-search-incident-report-2026-02-23.md b/docs/bioimage-archive-search-incident-report-2026-02-23.md new file mode 100644 index 00000000..f3569db9 --- /dev/null +++ b/docs/bioimage-archive-search-incident-report-2026-02-23.md @@ -0,0 +1,86 @@ +# BioImage Archive Search Incident Report (for Maintainers) + +Date: 2026-02-23 +Prepared by: RI-SCALE Model Hub team + +## 1) Summary +We observed repeated search failures from the BioImage Archive search endpoints during an end-user query flow. The same session later ended with a model/proxy timeout. + +Current status at the time of this report: direct re-probing of the same endpoint/query patterns returned HTTP 200 consistently, which suggests an intermittent upstream issue rather than a permanent outage. + +## 2) User-visible impact +- User request: "find me 5 mouse tumor datasets" +- Result during failing run: tool calls repeatedly failed, then final assistant message reported timeout. +- End-user message shown: "Error from proxy: Request timed out." + +## 3) Evidence from failing runtime session (captured logs) +The following tool calls failed with server-side 500 errors: + +- `search_datasets` with query `(mouse OR mice OR murine) AND (tumor OR tumour OR cancer)` + Error text: `Server error '500' for url 'https://beta.bioimagearchive.org/search/search/fts?query=(mouse%20OR%20mice%20OR%20murine)%20AND%20(tumor%20OR%20tumour%20OR%20cancer)'` + +- `search_datasets` with query `mouse OR mice OR murine OR tumor OR tumour OR cancer` + Error text: `Server error '500' for url 'https://beta.bioimagearchive.org/search/search/fts?query=mouse%20OR%20mice%20OR%20murine%20OR%20tumor%20OR%20tumour%20OR%20cancer'` + +- `search_datasets` with query `mouse OR tumor` + Error text: `Server error '500' for url 'https://beta.bioimagearchive.org/search/search/fts?query=mouse%20OR%20tumor'` + +- `search_images` with query `mouse AND tumor` + Error text: `Server error '500' for url 'https://beta.bioimagearchive.org/search/search/fts/image?query=mouse%20AND%20tumor'` + +Additional session outcome: +- Proxy/model loop eventually returned: `{"error": "Request timed out."}` + +## 4) Independent direct endpoint probe (performed after incident) +To verify endpoint health, we queried the same URL patterns directly from the client environment (outside browser CORS path). + +Probe window (UTC): 2026-02-23T12:52:32Z to 2026-02-23T12:52:41Z +Total requests: 15 +HTTP status distribution: 15x 200 + +### Probe results (timestamped) + +| timestamp_utc | attempt | endpoint | query_label | http_code | +|---|---:|---|---|---:| +| 2026-02-23T12:52:32Z | 1 | fts | q_complex | 200 | +| 2026-02-23T12:52:33Z | 1 | fts | q_or_long | 200 | +| 2026-02-23T12:52:34Z | 1 | fts | q_short | 200 | +| 2026-02-23T12:52:34Z | 1 | fts/image | q_image_and | 200 | +| 2026-02-23T12:52:35Z | 1 | fts/image | q_image_or | 200 | +| 2026-02-23T12:52:36Z | 2 | fts | q_complex | 200 | +| 2026-02-23T12:52:36Z | 2 | fts | q_or_long | 200 | +| 2026-02-23T12:52:37Z | 2 | fts | q_short | 200 | +| 2026-02-23T12:52:38Z | 2 | fts/image | q_image_and | 200 | +| 2026-02-23T12:52:38Z | 2 | fts/image | q_image_or | 200 | +| 2026-02-23T12:52:39Z | 3 | fts | q_complex | 200 | +| 2026-02-23T12:52:39Z | 3 | fts | q_or_long | 200 | +| 2026-02-23T12:52:40Z | 3 | fts | q_short | 200 | +| 2026-02-23T12:52:41Z | 3 | fts/image | q_image_and | 200 | +| 2026-02-23T12:52:41Z | 3 | fts/image | q_image_or | 200 | + +One sampled successful response during probe: +- URL: `https://beta.bioimagearchive.org/search/search/fts?query=mouse%20OR%20tumor` +- Status: `HTTP/2 200` +- Body prefix: `{"hits":{"total":{"value":0,"relation":"eq"},...}` (valid JSON payload) + +## 5) Assessment +Based on this evidence: +- The failure is real (multiple 500 responses during user session across both dataset and image search endpoints). +- The issue appears intermittent/transient (subsequent direct probes returned stable 200 responses). +- This pattern is consistent with temporary upstream backend/index/gateway instability rather than a persistent schema/query-format issue. + +## 6) Suggested maintainer checks (respectfully suggested) +- Review server logs for the failing timestamps around the user session (especially 5xx bursts on `/search/search/fts` and `/search/search/fts/image`). +- Check backend search cluster health, queue saturation, and timeouts. +- Check gateway/load balancer/WAF behavior for transient 5xx responses. +- Confirm whether any rolling deploy/reindex/maintenance occurred during the failure window. + +## 7) Repro URLs used +- `https://beta.bioimagearchive.org/search/search/fts?query=(mouse%20OR%20mice%20OR%20murine)%20AND%20(tumor%20OR%20tumour%20OR%20cancer)` +- `https://beta.bioimagearchive.org/search/search/fts?query=mouse%20OR%20mice%20OR%20murine%20OR%20tumor%20OR%20tumour%20OR%20cancer` +- `https://beta.bioimagearchive.org/search/search/fts?query=mouse%20OR%20tumor` +- `https://beta.bioimagearchive.org/search/search/fts/image?query=mouse%20AND%20tumor` +- `https://beta.bioimagearchive.org/search/search/fts/image?query=mouse%20OR%20tumor` + +--- +We appreciate the BioImage Archive team’s support and understand intermittent issues can happen in production systems. We hope this report is useful and are happy to provide any additional logs or run further directed probes if helpful. diff --git a/docs/bioimage-finder-startup-script.py b/docs/bioimage-finder-startup-script.py index 98739f56..e6e50d2e 100644 --- a/docs/bioimage-finder-startup-script.py +++ b/docs/bioimage-finder-startup-script.py @@ -13,6 +13,28 @@ BASE_SEARCH_URL = "https://beta.bioimagearchive.org/search/search/fts" BASE_IMAGE_SEARCH_URL = "https://beta.bioimagearchive.org/search/search/fts/image" +STOPWORDS = { + "and", + "or", + "not", + "the", + "a", + "an", + "of", + "for", + "with", + "in", + "on", + "to", + "please", + "give", + "me", + "find", + "show", + "get", + "dataset", + "datasets", +} def _short_text(value: Any, max_len: int = 180) -> str: @@ -25,6 +47,110 @@ def _build_url(base_url: str, query: str) -> str: return f"{base_url}?query={encoded}" +def _query_terms(query: str) -> List[str]: + terms: List[str] = [] + for token in re.findall(r"[A-Za-z0-9]+", query.lower()): + if len(token) < 3: + continue + if token in STOPWORDS: + continue + if token not in terms: + terms.append(token) + return terms + + +def _dataset_relevance_score(item: Dict[str, Any], query_terms: List[str]) -> float: + title = item.get("title") if isinstance(item.get("title"), str) else "" + description = item.get("description") if isinstance(item.get("description"), str) else "" + accession = item.get("accession") if isinstance(item.get("accession"), str) else "" + + title_lower = title.lower() + description_lower = description.lower() + accession_lower = accession.lower() + + score = 0.0 + term_hits = 0 + for term in query_terms: + term_re = re.compile(rf"\\b{re.escape(term)}\\b", re.IGNORECASE) + in_title = bool(term_re.search(title_lower)) + in_desc = bool(term_re.search(description_lower)) + if in_title: + score += 6.0 + term_hits += 1 + elif term in title_lower: + score += 3.5 + term_hits += 1 + + if in_desc: + score += 3.0 + term_hits += 1 + elif term in description_lower: + score += 1.0 + term_hits += 1 + + if term in accession_lower: + score += 0.5 + + if query_terms and term_hits >= max(2, len(query_terms)): + score += 2.0 + + api_score = item.get("score") + if isinstance(api_score, (int, float)): + score += min(float(api_score), 20.0) / 20.0 + + return score + + +def _rerank_dataset_results(items: List[Dict[str, Any]], query: str) -> List[Dict[str, Any]]: + terms = _query_terms(query) + if not terms: + return items + ranked: List[tuple[float, Dict[str, Any]]] = [] + for item in items: + ranked.append((_dataset_relevance_score(item, terms), item)) + ranked.sort(key=lambda pair: pair[0], reverse=True) + return [item for _, item in ranked] + + +def _has_strong_match(items: List[Dict[str, Any]], query: str) -> bool: + terms = _query_terms(query) + if not terms: + return True + for item in items: + if _dataset_relevance_score(item, terms) >= 6.0: + return True + return False + + +def _merge_unique_dataset_results( + primary: List[Dict[str, Any]], + secondary: List[Dict[str, Any]], +) -> List[Dict[str, Any]]: + merged: List[Dict[str, Any]] = [] + seen: set[str] = set() + + def _result_key(entry: Dict[str, Any]) -> str: + accession = entry.get("accession") + if isinstance(accession, str) and accession.strip(): + return f"acc:{accession.strip().lower()}" + url = entry.get("url") + if isinstance(url, str) and url.strip(): + return f"url:{url.strip().lower()}" + title = entry.get("title") + if isinstance(title, str) and title.strip(): + return f"title:{title.strip().lower()}" + return f"obj:{id(entry)}" + + for candidate in [*primary, *secondary]: + key = _result_key(candidate) + if key in seen: + continue + seen.add(key) + merged.append(candidate) + + return merged + + def _extract_hits_and_total( payload: Dict[str, Any] | Any, ) -> tuple[List[Dict[str, Any]], int]: @@ -271,6 +397,7 @@ def _format_dataset_assistant_summary(payload: Dict[str, Any], max_items: int = total = payload.get("total") if isinstance(total, int): lines.append(f"(Total hits reported by API: {total})") + lines.append("") lines.append("Note: BioImage Archive beta search can be incomplete or intermittent.") return "\n".join(lines) @@ -288,7 +415,10 @@ def _normalize_search_payload( result_items = raw_results if isinstance(raw_results, list) else [] compact_results: List[Dict[str, Any]] = [] - for entry in result_items[:result_limit]: + ranked_items = result_items + if kind == "datasets": + ranked_items = _rerank_dataset_results(result_items, query) + for entry in ranked_items[:result_limit]: if not isinstance(entry, dict): continue if kind == "datasets": @@ -361,8 +491,20 @@ def _fallback_terms_from_query(query: str) -> List[str]: return terms +def _fallback_candidate_terms(query: str) -> List[str]: + candidates: List[str] = [] + for term in _fallback_terms_from_query(query): + if term not in candidates: + candidates.append(term) + for term in _query_terms(query): + if term not in candidates: + candidates.append(term) + return candidates + + async def _search_datasets_once(query: str, safe_limit: int) -> Dict[str, Any]: - proxied = await _search_via_proxy("datasets", query, safe_limit) + fetch_limit = min(60, max(20, safe_limit * 6)) + proxied = await _search_via_proxy("datasets", query, fetch_limit) if isinstance(proxied, dict): if "error" in proxied: raise RuntimeError(proxied["error"]) @@ -377,7 +519,7 @@ async def _search_datasets_once(query: str, safe_limit: int) -> Dict[str, Any]: hits, total = _extract_hits_and_total(payload) top_hits: List[Dict[str, Any]] = [] - for item in hits[: max(1, safe_limit)]: + for item in hits[:fetch_limit]: if isinstance(item, dict): top_hits.append(_dataset_result_from_hit(item)) @@ -402,17 +544,69 @@ async def search_datasets(query: str, limit: int = 10) -> Dict[str, Any]: Dictionary with request URL, total count, and top results. """ safe_limit = max(1, int(limit)) + print(f"DEBUG: search_datasets primary query='{query}' limit={safe_limit}") primary_result = await _search_datasets_once(query, safe_limit) if isinstance(primary_result.get("total"), int) and primary_result.get("total", 0) > 0: + primary_items = primary_result.get("results") + primary_list = primary_items if isinstance(primary_items, list) else [] + print( + f"DEBUG: search_datasets primary query returned total={primary_result.get('total', 0)}" + ) + if not _has_strong_match(primary_list, query) and len(_query_terms(query)) >= 2: + for term in _fallback_candidate_terms(query)[:4]: + print( + f"DEBUG: search_datasets enrichment query='{term}' after weak relevance in primary query='{query}'" + ) + enrichment_result = await _search_datasets_once(term, safe_limit) + enrichment_items = enrichment_result.get("results") + enrichment_list = ( + enrichment_items if isinstance(enrichment_items, list) else [] + ) + merged_results = _merge_unique_dataset_results(primary_list, enrichment_list) + reranked = _rerank_dataset_results(merged_results, query) + primary_result["results"] = reranked[: min(8, safe_limit)] + primary_result["assistant_summary"] = _format_dataset_assistant_summary( + primary_result, max_items=min(5, safe_limit) + ) + if _has_strong_match(primary_result["results"], query): + primary_result["enriched_with_query"] = term + break return primary_result - if " and " in query.lower(): - for term in _fallback_terms_from_query(query)[:2]: + fallback_candidates = _fallback_candidate_terms(query) + if len(fallback_candidates) >= 1: + merged_results: List[Dict[str, Any]] = [] + fallback_terms_used: List[str] = [] + for term in fallback_candidates[:4]: + print( + f"DEBUG: search_datasets fallback query='{term}' after empty primary query='{query}'" + ) fallback_result = await _search_datasets_once(term, safe_limit) - if isinstance(fallback_result.get("total"), int) and fallback_result.get("total", 0) > 0: - fallback_result["fallback_from_query"] = query - return fallback_result - + fallback_items = fallback_result.get("results") + fallback_list = fallback_items if isinstance(fallback_items, list) else [] + if fallback_list: + merged_results = _merge_unique_dataset_results(merged_results, fallback_list) + fallback_terms_used.append(term) + print( + f"DEBUG: search_datasets fallback query returned total={fallback_result.get('total', 0)}" + ) + + if merged_results: + reranked = _rerank_dataset_results(merged_results, query) + aggregated: Dict[str, Any] = { + "query": query, + "url": _build_url(BASE_SEARCH_URL, query), + "total": len(merged_results), + "results": reranked[: min(8, safe_limit)], + "fallback_from_query": query, + "fallback_terms_used": fallback_terms_used, + } + aggregated["assistant_summary"] = _format_dataset_assistant_summary( + aggregated, max_items=min(5, safe_limit) + ) + return aggregated + + print(f"DEBUG: search_datasets no results for query='{query}'") return primary_result @@ -526,9 +720,13 @@ async def _probe_beta_index() -> List[str]: - The beta index is limited/incomplete, so infer likely terms from startup probe output. - When querying based on the user's prompt, start very briefly. - Prefer OR-style brief queries first (for example: "mouse OR tumor"). +- For multi-concept dataset requests (for example "mouse tumor"), run at least two dataset tool calls even if the first call returns enough results. +- A good default sequence is: (1) broad OR query, then (2) one high-prior single-term query from startup probe totals. +- If a follow-up model/proxy call times out after tool results already exist, ignore that transient timeout and finalize using available tool results; do not mention backend/proxy timeouts to the user. - If an OR query returns no dataset results, do not switch to AND. Immediately try single-term fallbacks. -- If queries fail repeatedly or are empty, simplify to single-term fallbacks ("tumor", "mouse", "cancer"). -- Make at most two fallback calls, then provide a best-effort final answer and explicitly mention beta limitations. +- If queries fail repeatedly or are empty, simplify to single-term fallbacks ("mouse", "tumor", "cancer", "neuroblastoma"). +- Use startup probe totals as your prior about likely matches. +- Make up to four fallback calls, then provide a best-effort final answer and explicitly mention beta limitations. - If any dataset query already returns at least the requested number of results, stop calling tools and answer immediately. - Tool outputs are intentionally compact and may include an assistant_summary field; use that summary when finalizing under timeout/fallback. Then provide a concise human summary with links/accessions whenever available. diff --git a/e2e/agent-chat-mouse-tumor-regression.spec.ts b/e2e/agent-chat-mouse-tumor-regression.spec.ts index 502afeeb..08f76910 100644 --- a/e2e/agent-chat-mouse-tumor-regression.spec.ts +++ b/e2e/agent-chat-mouse-tumor-regression.spec.ts @@ -32,6 +32,13 @@ async function waitForCompletedAssistantMessage(page: Page, beforeAssistantCount throw new Error('Timed out waiting for assistant response completion'); } +async function selectModel(page: Page, modelValue: string) { + const select = page.locator('#chat-model-select'); + await expect(select).toBeVisible({ timeout: CHAT_READY_TIMEOUT }); + await select.selectOption(modelValue); + await expect(select).toHaveValue(modelValue); +} + test.describe('BioImage Finder mouse-tumor regression', () => { test('does not emit archive bridge/to_py kernel errors', async ({ page }) => { test.skip(!process.env.RUN_REAL_PROXY_REPRO, 'Set RUN_REAL_PROXY_REPRO=1 to run against real proxy/kernel flow.'); @@ -83,4 +90,39 @@ test.describe('BioImage Finder mouse-tumor regression', () => { expect(assistantText).toMatch(/tumor|mouse|cancer/i); } }); + + for (const modelValue of ['gpt-5-mini', 'gpt-5']) { + test(`should return live datasets for mouse tumor prompt (${modelValue}, real flow)`, async ({ page }) => { + test.skip(!process.env.RUN_REAL_PROXY_REPRO, 'Set RUN_REAL_PROXY_REPRO=1 to run against real proxy/kernel flow.'); + test.skip(!process.env.RUN_REAL_PROXY_MODEL_MATRIX, 'Set RUN_REAL_PROXY_MODEL_MATRIX=1 to run model-matrix real-proxy checks.'); + test.setTimeout(480_000); + + const input = await openAgentChat(page); + await page.evaluate(() => { + (globalThis as any).__chatProxyTestMode = undefined; + }); + await selectModel(page, modelValue); + + const assistantHeaders = page.locator('span.text-xs.font-semibold'); + const beforeAssistantCount = await assistantHeaders.count(); + + await input.fill(PROMPT); + await input.press('Enter'); + + const assistantMessage = await waitForCompletedAssistantMessage(page, beforeAssistantCount); + const assistantText = ((await assistantMessage.innerText()) || '').toLowerCase(); + + expect(assistantText).not.toMatch(/archive search bridge is currently unavailable|archive bridge is currently unavailable|search service is currently unavailable/); + expect(assistantText).toMatch(/s-biad\d+|bioimage-archive\/[a-z0-9-]+|api is currently in beta|beta and appears limited|best available results\/errors/i); + if (!/s-biad\d+|bioimage-archive\/[a-z0-9-]+/i.test(assistantText)) { + expect(assistantText).toMatch(/tumor|mouse|cancer|neuroblastoma/i); + } + + await page.getByRole('button', { name: 'Toggle Logs' }).click(); + const logsText = (await page.locator('div.w-80.bg-gray-900').innerText().catch(() => '')).toLowerCase(); + if (modelValue !== 'gpt-5-mini') { + expect(logsText).toContain(`model switched: gpt-5-mini -> ${modelValue}`); + } + }); + } }); diff --git a/scripts/agent_startup_scripts/bioimage_finder_startup_script.py b/scripts/agent_startup_scripts/bioimage_finder_startup_script.py index 98739f56..e6e50d2e 100644 --- a/scripts/agent_startup_scripts/bioimage_finder_startup_script.py +++ b/scripts/agent_startup_scripts/bioimage_finder_startup_script.py @@ -13,6 +13,28 @@ BASE_SEARCH_URL = "https://beta.bioimagearchive.org/search/search/fts" BASE_IMAGE_SEARCH_URL = "https://beta.bioimagearchive.org/search/search/fts/image" +STOPWORDS = { + "and", + "or", + "not", + "the", + "a", + "an", + "of", + "for", + "with", + "in", + "on", + "to", + "please", + "give", + "me", + "find", + "show", + "get", + "dataset", + "datasets", +} def _short_text(value: Any, max_len: int = 180) -> str: @@ -25,6 +47,110 @@ def _build_url(base_url: str, query: str) -> str: return f"{base_url}?query={encoded}" +def _query_terms(query: str) -> List[str]: + terms: List[str] = [] + for token in re.findall(r"[A-Za-z0-9]+", query.lower()): + if len(token) < 3: + continue + if token in STOPWORDS: + continue + if token not in terms: + terms.append(token) + return terms + + +def _dataset_relevance_score(item: Dict[str, Any], query_terms: List[str]) -> float: + title = item.get("title") if isinstance(item.get("title"), str) else "" + description = item.get("description") if isinstance(item.get("description"), str) else "" + accession = item.get("accession") if isinstance(item.get("accession"), str) else "" + + title_lower = title.lower() + description_lower = description.lower() + accession_lower = accession.lower() + + score = 0.0 + term_hits = 0 + for term in query_terms: + term_re = re.compile(rf"\\b{re.escape(term)}\\b", re.IGNORECASE) + in_title = bool(term_re.search(title_lower)) + in_desc = bool(term_re.search(description_lower)) + if in_title: + score += 6.0 + term_hits += 1 + elif term in title_lower: + score += 3.5 + term_hits += 1 + + if in_desc: + score += 3.0 + term_hits += 1 + elif term in description_lower: + score += 1.0 + term_hits += 1 + + if term in accession_lower: + score += 0.5 + + if query_terms and term_hits >= max(2, len(query_terms)): + score += 2.0 + + api_score = item.get("score") + if isinstance(api_score, (int, float)): + score += min(float(api_score), 20.0) / 20.0 + + return score + + +def _rerank_dataset_results(items: List[Dict[str, Any]], query: str) -> List[Dict[str, Any]]: + terms = _query_terms(query) + if not terms: + return items + ranked: List[tuple[float, Dict[str, Any]]] = [] + for item in items: + ranked.append((_dataset_relevance_score(item, terms), item)) + ranked.sort(key=lambda pair: pair[0], reverse=True) + return [item for _, item in ranked] + + +def _has_strong_match(items: List[Dict[str, Any]], query: str) -> bool: + terms = _query_terms(query) + if not terms: + return True + for item in items: + if _dataset_relevance_score(item, terms) >= 6.0: + return True + return False + + +def _merge_unique_dataset_results( + primary: List[Dict[str, Any]], + secondary: List[Dict[str, Any]], +) -> List[Dict[str, Any]]: + merged: List[Dict[str, Any]] = [] + seen: set[str] = set() + + def _result_key(entry: Dict[str, Any]) -> str: + accession = entry.get("accession") + if isinstance(accession, str) and accession.strip(): + return f"acc:{accession.strip().lower()}" + url = entry.get("url") + if isinstance(url, str) and url.strip(): + return f"url:{url.strip().lower()}" + title = entry.get("title") + if isinstance(title, str) and title.strip(): + return f"title:{title.strip().lower()}" + return f"obj:{id(entry)}" + + for candidate in [*primary, *secondary]: + key = _result_key(candidate) + if key in seen: + continue + seen.add(key) + merged.append(candidate) + + return merged + + def _extract_hits_and_total( payload: Dict[str, Any] | Any, ) -> tuple[List[Dict[str, Any]], int]: @@ -271,6 +397,7 @@ def _format_dataset_assistant_summary(payload: Dict[str, Any], max_items: int = total = payload.get("total") if isinstance(total, int): lines.append(f"(Total hits reported by API: {total})") + lines.append("") lines.append("Note: BioImage Archive beta search can be incomplete or intermittent.") return "\n".join(lines) @@ -288,7 +415,10 @@ def _normalize_search_payload( result_items = raw_results if isinstance(raw_results, list) else [] compact_results: List[Dict[str, Any]] = [] - for entry in result_items[:result_limit]: + ranked_items = result_items + if kind == "datasets": + ranked_items = _rerank_dataset_results(result_items, query) + for entry in ranked_items[:result_limit]: if not isinstance(entry, dict): continue if kind == "datasets": @@ -361,8 +491,20 @@ def _fallback_terms_from_query(query: str) -> List[str]: return terms +def _fallback_candidate_terms(query: str) -> List[str]: + candidates: List[str] = [] + for term in _fallback_terms_from_query(query): + if term not in candidates: + candidates.append(term) + for term in _query_terms(query): + if term not in candidates: + candidates.append(term) + return candidates + + async def _search_datasets_once(query: str, safe_limit: int) -> Dict[str, Any]: - proxied = await _search_via_proxy("datasets", query, safe_limit) + fetch_limit = min(60, max(20, safe_limit * 6)) + proxied = await _search_via_proxy("datasets", query, fetch_limit) if isinstance(proxied, dict): if "error" in proxied: raise RuntimeError(proxied["error"]) @@ -377,7 +519,7 @@ async def _search_datasets_once(query: str, safe_limit: int) -> Dict[str, Any]: hits, total = _extract_hits_and_total(payload) top_hits: List[Dict[str, Any]] = [] - for item in hits[: max(1, safe_limit)]: + for item in hits[:fetch_limit]: if isinstance(item, dict): top_hits.append(_dataset_result_from_hit(item)) @@ -402,17 +544,69 @@ async def search_datasets(query: str, limit: int = 10) -> Dict[str, Any]: Dictionary with request URL, total count, and top results. """ safe_limit = max(1, int(limit)) + print(f"DEBUG: search_datasets primary query='{query}' limit={safe_limit}") primary_result = await _search_datasets_once(query, safe_limit) if isinstance(primary_result.get("total"), int) and primary_result.get("total", 0) > 0: + primary_items = primary_result.get("results") + primary_list = primary_items if isinstance(primary_items, list) else [] + print( + f"DEBUG: search_datasets primary query returned total={primary_result.get('total', 0)}" + ) + if not _has_strong_match(primary_list, query) and len(_query_terms(query)) >= 2: + for term in _fallback_candidate_terms(query)[:4]: + print( + f"DEBUG: search_datasets enrichment query='{term}' after weak relevance in primary query='{query}'" + ) + enrichment_result = await _search_datasets_once(term, safe_limit) + enrichment_items = enrichment_result.get("results") + enrichment_list = ( + enrichment_items if isinstance(enrichment_items, list) else [] + ) + merged_results = _merge_unique_dataset_results(primary_list, enrichment_list) + reranked = _rerank_dataset_results(merged_results, query) + primary_result["results"] = reranked[: min(8, safe_limit)] + primary_result["assistant_summary"] = _format_dataset_assistant_summary( + primary_result, max_items=min(5, safe_limit) + ) + if _has_strong_match(primary_result["results"], query): + primary_result["enriched_with_query"] = term + break return primary_result - if " and " in query.lower(): - for term in _fallback_terms_from_query(query)[:2]: + fallback_candidates = _fallback_candidate_terms(query) + if len(fallback_candidates) >= 1: + merged_results: List[Dict[str, Any]] = [] + fallback_terms_used: List[str] = [] + for term in fallback_candidates[:4]: + print( + f"DEBUG: search_datasets fallback query='{term}' after empty primary query='{query}'" + ) fallback_result = await _search_datasets_once(term, safe_limit) - if isinstance(fallback_result.get("total"), int) and fallback_result.get("total", 0) > 0: - fallback_result["fallback_from_query"] = query - return fallback_result - + fallback_items = fallback_result.get("results") + fallback_list = fallback_items if isinstance(fallback_items, list) else [] + if fallback_list: + merged_results = _merge_unique_dataset_results(merged_results, fallback_list) + fallback_terms_used.append(term) + print( + f"DEBUG: search_datasets fallback query returned total={fallback_result.get('total', 0)}" + ) + + if merged_results: + reranked = _rerank_dataset_results(merged_results, query) + aggregated: Dict[str, Any] = { + "query": query, + "url": _build_url(BASE_SEARCH_URL, query), + "total": len(merged_results), + "results": reranked[: min(8, safe_limit)], + "fallback_from_query": query, + "fallback_terms_used": fallback_terms_used, + } + aggregated["assistant_summary"] = _format_dataset_assistant_summary( + aggregated, max_items=min(5, safe_limit) + ) + return aggregated + + print(f"DEBUG: search_datasets no results for query='{query}'") return primary_result @@ -526,9 +720,13 @@ async def _probe_beta_index() -> List[str]: - The beta index is limited/incomplete, so infer likely terms from startup probe output. - When querying based on the user's prompt, start very briefly. - Prefer OR-style brief queries first (for example: "mouse OR tumor"). +- For multi-concept dataset requests (for example "mouse tumor"), run at least two dataset tool calls even if the first call returns enough results. +- A good default sequence is: (1) broad OR query, then (2) one high-prior single-term query from startup probe totals. +- If a follow-up model/proxy call times out after tool results already exist, ignore that transient timeout and finalize using available tool results; do not mention backend/proxy timeouts to the user. - If an OR query returns no dataset results, do not switch to AND. Immediately try single-term fallbacks. -- If queries fail repeatedly or are empty, simplify to single-term fallbacks ("tumor", "mouse", "cancer"). -- Make at most two fallback calls, then provide a best-effort final answer and explicitly mention beta limitations. +- If queries fail repeatedly or are empty, simplify to single-term fallbacks ("mouse", "tumor", "cancer", "neuroblastoma"). +- Use startup probe totals as your prior about likely matches. +- Make up to four fallback calls, then provide a best-effort final answer and explicitly mention beta limitations. - If any dataset query already returns at least the requested number of results, stop calling tools and answer immediately. - Tool outputs are intentionally compact and may include an assistant_summary field; use that summary when finalizing under timeout/fallback. Then provide a concise human summary with links/accessions whenever available. diff --git a/src/pages/AgentPage.tsx b/src/pages/AgentPage.tsx index 4b232884..2c0550be 100644 --- a/src/pages/AgentPage.tsx +++ b/src/pages/AgentPage.tsx @@ -28,6 +28,11 @@ interface SessionInputDraftCache { [sessionKey: string]: string; } +interface UiEventEntry { + timestamp: string; + message: string; +} + const MAX_COLLAPSED_MESSAGE_CHARS = 1200; const MAX_COLLAPSED_MESSAGE_LINES = 16; const MAX_PROGRESS_TRACE_DETAILS = 30; @@ -68,8 +73,8 @@ const CHAT_PROXY_REQUEST_TIMEOUT_MS = 900_000; const CHAT_PROXY_RESOLVE_TIMEOUT_MS = 60_000; const CHAT_PROXY_COMPLETION_TIMEOUT_MS = 900_000; const AGENT_TOOL_EXECUTION_LIMIT = 50; -const AGENT_ITERATION_SOFT_TIMEOUT_MS = 90_000; -const AGENT_ITERATION_HARD_TIMEOUT_MS = 120_000; +const AGENT_ITERATION_SOFT_TIMEOUT_MS = 60_000; +const AGENT_ITERATION_HARD_TIMEOUT_MS = 80_000; const slugifyBranchName = (branchName: string): string => { const normalized = branchName @@ -107,6 +112,30 @@ const normalizeBranchRefName = (value: string): string => { return trimmed; }; +const deriveAgentInstructionPrompt = (rawPrompt: string): string => { + const trimmed = rawPrompt.trim(); + if (!trimmed) return ''; + + const looksLikeCode = /\bimport\s+\w+|\bdef\s+\w+\(|\basync\s+def\s+\w+\(|\n\s*print\(/.test(trimmed); + if (!looksLikeCode) { + return trimmed; + } + + const printTripleQuoteMatch = trimmed.match(/print\(\s*"""([\s\S]*?)"""\s*\)/m); + if (printTripleQuoteMatch && printTripleQuoteMatch[1]) { + const extracted = printTripleQuoteMatch[1].trim(); + if (extracted) return extracted; + } + + const youAreIndex = trimmed.lastIndexOf('You are '); + if (youAreIndex >= 0) { + const extracted = trimmed.slice(youAreIndex).trim(); + if (extracted) return extracted; + } + + return trimmed.slice(0, 3000).trim(); +}; + const uniqueNonEmptyValues = (values: Array): string[] => { const seen = new Set(); const result: string[] = []; @@ -119,6 +148,13 @@ const uniqueNonEmptyValues = (values: Array): string[ return result; }; +const escapeForPythonSingleQuotedString = (value: string): string => { + const backslash = String.fromCharCode(92); + return value + .replaceAll(backslash, `${backslash}${backslash}`) + .replaceAll("'", `${backslash}'`); +}; + const getChatProxyServiceIds = (): string[] => { const isProductionBuild = process.env.NODE_ENV === 'production'; const configuredAppId = (process.env.REACT_APP_CHAT_PROXY_APP_ID || '').trim(); @@ -264,6 +300,7 @@ const AgentPage: React.FC = () => { const [agentReady, setAgentReady] = useState(false); const [agentProgress, setAgentProgress] = useState(null); const [agentProgressDetails, setAgentProgressDetails] = useState([]); + const [uiEvents, setUiEvents] = useState([]); const [showAgentProgressDetails, setShowAgentProgressDetails] = useState(false); const [typingSessionKey, setTypingSessionKey] = useState(null); const [expandedMessageIds, setExpandedMessageIds] = useState>({}); @@ -1029,10 +1066,10 @@ const AgentPage: React.FC = () => { if (packages.length > 0) { const packagesJson = JSON.stringify(packages); - const chatProxyServiceIdsLiteral = JSON.stringify(CHAT_PROXY_SERVICE_IDS) - .replaceAll('\\', '\\\\') - .replaceAll("'", "\\'"); - const installCode = ` + const chatProxyServiceIdsLiteral = escapeForPythonSingleQuotedString( + JSON.stringify(CHAT_PROXY_SERVICE_IDS) + ); + const installCode = String.raw` import micropip import json import traceback @@ -1303,7 +1340,21 @@ async def hypha_chat_proxy(messages_json, tools_json, tool_choice_json, model): if tool_choice is not None: print(f"TRACE: proxy tool_choice => {json.dumps(tool_choice, ensure_ascii=False)}") + fallback_model = '${DEFAULT_CHAT_MODEL}' result = await _python_fallback_chat_completion(messages, tools, tool_choice, model) + + if ( + isinstance(result, dict) + and "error" in result + and isinstance(model, str) + and model.strip() + and model.strip() != fallback_model + ): + print(f"DEBUG: Model {model} returned error payload; retrying once with fallback model {fallback_model}") + fallback_result = await _python_fallback_chat_completion(messages, tools, tool_choice, fallback_model) + if not (isinstance(fallback_result, dict) and "error" in fallback_result): + result = fallback_result + print(f"TRACE: proxy response => {json.dumps(result, ensure_ascii=False)}") return json.dumps(result) except asyncio.TimeoutError: @@ -1750,6 +1801,11 @@ _install_httpx_proxy_patch() }; }; + const appendUiEvent = (message: string) => { + const timestamp = new Date().toISOString(); + setUiEvents(prev => [...prev, { timestamp, message }].slice(-100)); + }; + const parseProgressFromStdout = (rawContent: string) => { const lines = rawContent .split('\n') @@ -1783,6 +1839,11 @@ _install_httpx_proxy_patch() } if (line.startsWith('DEBUG: Exception in hypha_chat_proxy bridge:')) { updateAgentProgress('Chat proxy bridge exception.', line); + continue; + } + if (line.includes('DEBUG: Empty assistant response; requesting forced final answer')) { + updateAgentProgress('Finishing response after empty model output...', line); + continue; } } }; @@ -1856,7 +1917,10 @@ _install_httpx_proxy_patch() .map(m => ({ role: m.role, content: m.content })); if (agentSystemPrompt) { - history.unshift({ role: 'system', content: agentSystemPrompt }); + const condensedAgentPrompt = deriveAgentInstructionPrompt(agentSystemPrompt); + if (condensedAgentPrompt) { + history.unshift({ role: 'system', content: condensedAgentPrompt }); + } } history.unshift({ @@ -1908,7 +1972,7 @@ _install_httpx_proxy_patch() } const historyJsonBase64 = toBase64Utf8(JSON.stringify(history)); - const chatModel = (agentChatModel || DEFAULT_CHAT_MODEL).replaceAll('\\', '\\\\').replaceAll("'", "\\'"); + const chatModel = escapeForPythonSingleQuotedString(agentChatModel || DEFAULT_CHAT_MODEL); const runChatExecution = async (): Promise => { return await new Promise(async (resolve, reject) => { @@ -1946,7 +2010,7 @@ _install_httpx_proxy_patch() ); }, CHAT_PROXY_REQUEST_TIMEOUT_MS); - const code = ` + const code = String.raw` import asyncio import js import json @@ -2061,8 +2125,58 @@ async def _chat_wrapper(): return if isinstance(result, dict) and "error" in result: - send_response({"text": "I’m having trouble reaching the chat backend right now. Please try again in a moment."}) - return + error_text = str(result.get("error", "")).lower() + is_timeout_like = any(token in error_text for token in [ + "timeout", + "timed out", + "gateway timeout", + "request_timeout", + "bridge-timeout", + "deadline exceeded", + ]) + + if is_timeout_like: + condensed_messages = [] + for msg in messages: + if isinstance(msg, dict) and msg.get("role") == "system": + condensed_messages.append(msg) + + last_user = None + for msg in reversed(messages): + if isinstance(msg, dict) and msg.get("role") == "user": + maybe_content = msg.get("content") + if isinstance(maybe_content, str) and maybe_content.strip(): + last_user = {"role": "user", "content": maybe_content.strip()} + break + + if isinstance(last_user, dict): + condensed_messages.append(last_user) + + if condensed_messages: + print("DEBUG: Initial model call timed out; retrying once with condensed context") + condensed_result_json = await hypha_chat_proxy( + json.dumps(condensed_messages), + tools_json, + tool_choice_json, + '${chatModel}' + ) + print(f"DEBUG: condensed retry hypha_chat_proxy returned: {condensed_result_json[:100]}...") + try: + condensed_result = json.loads(condensed_result_json) + except Exception: + condensed_result = None + + if isinstance(condensed_result, dict) and "error" not in condensed_result: + result = condensed_result + else: + send_response({"text": "I’m having trouble completing this request right now. Please try again in a moment."}) + return + else: + send_response({"text": "I’m having trouble completing this request right now. Please try again in a moment."}) + return + else: + send_response({"text": "I’m having trouble completing this request right now. Please try again in a moment."}) + return choice = result['choices'][0] response_message = choice['message'] @@ -2132,7 +2246,7 @@ async def _chat_wrapper(): if len(successful_tool_results) > 5: lines.append(f"...and {len(successful_tool_results) - 5} more tool result(s).") - return "\\n".join(lines) + return "\n".join(lines) def _is_timeout_error_payload(payload): if isinstance(payload, dict): @@ -2226,7 +2340,14 @@ async def _chat_wrapper(): async def _call_follow_up(messages_payload, tools_payload, tool_choice_payload): _ensure_latest_tool_call_responses(messages_payload) remaining_seconds = soft_deadline - asyncio.get_event_loop().time() - timeout_seconds = min(35.0, max(5.0, remaining_seconds + 2.0)) + model_lower = '${chatModel}'.strip().lower() + slower_model = ( + model_lower == 'gpt-5' + or model_lower.startswith('gpt-5.') + or model_lower == 'o3' + ) + max_followup_timeout = 32.0 if slower_model else 20.0 + timeout_seconds = min(max_followup_timeout, max(4.0, remaining_seconds + 1.0)) try: return await asyncio.wait_for( hypha_chat_proxy( @@ -2245,7 +2366,7 @@ async def _chat_wrapper(): summary_text = _summarize_tool_results() if isinstance(summary_text, str) and summary_text: send_response({ - "text": f"{summary_text}\\n\\nI’m returning the best results gathered so far.", + "text": f"{summary_text}\n\nI’m returning the best results gathered so far.", }) else: send_response({"text": "I’m taking longer than expected, so I’m stopping here. Please try again."}) @@ -2255,7 +2376,37 @@ async def _chat_wrapper(): content = response_message.get('content') if not tool_calls: - send_response({"text": content}) + if isinstance(content, str) and content.strip(): + send_response({"text": content}) + return + + print("DEBUG: Empty assistant response; requesting forced final answer") + + if successful_tool_results: + summary_text = _summarize_tool_results() + if isinstance(summary_text, str) and summary_text: + send_response({"text": f"{summary_text}\n\nI’m returning the best results gathered so far."}) + return + + messages.append({ + "role": "system", + "content": "Your previous response was empty. Provide a concise final answer now. If no results were found, explain that clearly and suggest one or two nearby search terms. Do not call additional tools.", + }) + + forced_empty_json = await _call_follow_up(messages, tools, "none") + try: + forced_empty_result = json.loads(forced_empty_json) + if isinstance(forced_empty_result, dict) and "error" in forced_empty_result: + raise RuntimeError(str(forced_empty_result.get("error"))) + forced_empty_message = forced_empty_result['choices'][0]['message'] if isinstance(forced_empty_result, dict) else {} + forced_empty_content = forced_empty_message.get('content') if isinstance(forced_empty_message, dict) else None + if isinstance(forced_empty_content, str) and forced_empty_content.strip(): + send_response({"text": forced_empty_content}) + return + except Exception: + pass + + send_response({"text": "I'm having trouble finishing this request right now. Please try again."}) return if turns >= max_turns: @@ -2324,7 +2475,7 @@ async def _chat_wrapper(): if dataset_query_failures >= 2 and not single_term_hint_added: messages.append({ "role": "system", - "content": "Dataset queries have repeatedly failed. Simplify now to single-term fallback queries likely present in the beta index (for example: tumor, mouse, cancer). Make at most two fallback calls, then provide the best possible final answer and explicitly mention beta index limitations.", + "content": "Dataset queries have repeatedly failed. Simplify now to single-term fallback queries likely present in the beta index (for example: mouse, tumor, cancer, neuroblastoma). Make up to four fallback calls, then provide the best possible final answer and explicitly mention beta index limitations.", }) single_term_hint_added = True successful_tool_results.append({ @@ -2350,7 +2501,7 @@ async def _chat_wrapper(): summary_text = _summarize_tool_results() if isinstance(summary_text, str) and summary_text: send_response({ - "text": f"{summary_text}\\n\\nI could not find exact matches for the original request. The BioImage Archive API is currently in beta and appears limited/intermittent. I used simplified fallback queries and returned the best available results/errors.", + "text": f"{summary_text}\n\nI could not find exact matches for the original request. The BioImage Archive API is currently in beta and appears limited/intermittent. I used simplified fallback queries and returned the best available results/errors.", }) return @@ -2365,7 +2516,7 @@ async def _chat_wrapper(): summary_text = _summarize_tool_results() if isinstance(summary_text, str) and summary_text: send_response({ - "text": f"{summary_text}\\n\\nI’m returning the best results gathered so far.", + "text": f"{summary_text}\n\nI’m returning the best results gathered so far.", }) return @@ -2380,13 +2531,18 @@ async def _chat_wrapper(): if successful_tool_results: summary_text = _summarize_tool_results() if isinstance(summary_text, str) and summary_text: - send_response({"text": f"{summary_text}\\n\\nI’m returning the best results gathered so far due to a temporary backend issue."}) + send_response({"text": f"{summary_text}\n\nI’m returning the best available results/errors so far."}) return send_response({"text": "I’m having trouble finishing this request right now. Please try again."}) return if isinstance(next_result, dict) and "error" in next_result: if _is_timeout_error_payload(next_result) and not timeout_finalize_requested and total_tool_calls > 0: + if successful_tool_results: + summary_text = _summarize_tool_results() + if isinstance(summary_text, str) and summary_text: + send_response({"text": f"{summary_text}\n\nI’m returning the best available results/errors so far."}) + return timeout_finalize_requested = True messages.append({ "role": "system", @@ -2408,7 +2564,7 @@ async def _chat_wrapper(): if successful_tool_results: summary_text = _summarize_tool_results() if isinstance(summary_text, str) and summary_text: - send_response({"text": f"{summary_text}\\n\\nI’m returning the best results gathered so far due to a temporary backend issue."}) + send_response({"text": f"{summary_text}\n\nI’m returning the best available results/errors so far."}) return send_response({"text": "I’m having trouble completing this request right now. Please try again in a moment."}) return @@ -2416,7 +2572,7 @@ async def _chat_wrapper(): response_message = next_result['choices'][0]['message'] except Exception as e: - traceback.print_exc() + traceback.print_exc() send_response({"text": "I hit a temporary runtime issue while preparing the response. Please try again."}) await _chat_wrapper() @@ -3011,7 +3167,8 @@ await _chat_wrapper() ]; const kernelLogLines = kernelExecutionLog.map((log) => `[${log.type}]${log.content}`); - const combinedCopyLines = [...kernelLogLines, ...persistedProgressLines, ...liveProgressLines]; + const uiEventLines = uiEvents.map((entry) => `[ui] ${entry.timestamp} ${entry.message}`); + const combinedCopyLines = [...kernelLogLines, ...uiEventLines, ...persistedProgressLines, ...liveProgressLines]; return ( <> @@ -3044,6 +3201,17 @@ await _chat_wrapper() {log.content} ))} + {uiEvents.length > 0 && ( + <> +
UI EVENTS
+ {uiEvents.map((entry, idx) => ( +
+ [ui] + {entry.timestamp} {entry.message} +
+ ))} + + )} {(persistedProgressLines.length > 0 || liveProgressLines.length > 0) && ( <>
MESSAGE PROGRESS TRACE
@@ -3087,7 +3255,16 @@ await _chat_wrapper()