File size: 6,545 Bytes
f044891 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 | # cxr-agent/tools/reason.py
"""
tools/reason.py
===============
Chest-X-ray reasoning via **NV-Reason-CXR-3B**, called *host-side* so the
base64 image never enters the model's context window.
Why this exists
---------------
The reasoning MCP tool (`reason_cxr` / `analyze_cxr`) takes a base64-encoded PNG.
If that were an agent-facing MCP tool, the orchestrator LLM would have to (1)
receive the base64 from an `encode_image` tool result and (2) pass it straight
back as a `reason_cxr` argument — putting *two* copies of a multi-megabyte
string into the conversation and blowing the context window ("Your input exceeds
the context window of this model").
Instead, `run_reasoning` resizes + encodes the image and calls the MCP through
the Agents SDK's own MCP client, returning only the **text** report. The model
passes a file path in and gets words back; the pixels are never tokenized. This
mirrors `tools/localize.py`.
Transport / contract (from skills/cxr_reasoning)
------------------------------------------------
Streamable-HTTP FastMCP server exposing `reason_cxr` (full) and `analyze_cxr`
(quick). Both take ``image_b64``, ``image_id``, ``prompt`` and return JSON
``{success, answer, thinking, raw_text, disclaimer}`` (or ``{success:false,
error}``). Images must be resized to ≤1280px before encoding or Qwen2.5-VL
produces garbled output.
Environment:
CXR_REASON_MCP_URL reasoning MCP URL (else built-in default)
CXR_REASON_TIMEOUT per-call read timeout, seconds (default 120)
CXR_REASON_MAX_SIDE longest-side resize cap before encoding (default 1280)
CXR_REASON_THINK_CHARS cap on returned thinking text (default 6000)
"""
from __future__ import annotations
import json
import logging
import os
from pathlib import Path
from typing import Any
logger = logging.getLogger(__name__)
DEFAULT_REASON_MCP_URL = "https://mcp-nv-reason-cxr-3b.fastmcp.app/mcp"
DEFAULT_TIMEOUT = 120.0
DEFAULT_MAX_SIDE = 1280
DEFAULT_THINK_CHARS = 6000
DEFAULT_PROMPT = "Find abnormalities and support devices."
def _extract_tool_text(result: Any) -> str:
"""Concatenate text from an MCP CallToolResult's content blocks."""
content = getattr(result, "content", None) or []
out: list[str] = []
for block in content:
text = getattr(block, "text", None)
if text:
out.append(text)
return "\n".join(out).strip()
def _cap(text: Any, limit: int) -> str:
s = "" if text is None else str(text)
return s if len(s) <= limit else s[:limit] + " …"
async def run_reasoning(
image_path: str,
prompt: str | None = None,
*,
quick: bool = False,
) -> dict:
"""
Run NV-Reason-CXR-3B on a CXR and return a *text-only* result dict.
Args:
image_path: Path to the (already triaged) CXR image.
prompt: Clinical question / instruction. Falls back to a safe default.
quick: Use the lighter ``analyze_cxr`` tool instead of ``reason_cxr``.
Returns:
``{"success": bool, "answer": str, "thinking": str, "disclaimer": str,
"image_id": str, "error"?: str}``. No base64 — safe to return to the
model. On any infra error ``success`` is False and ``error`` is set.
"""
url = os.environ.get("CXR_REASON_MCP_URL", DEFAULT_REASON_MCP_URL)
try:
timeout = float(os.environ.get("CXR_REASON_TIMEOUT", DEFAULT_TIMEOUT))
except ValueError:
timeout = DEFAULT_TIMEOUT
try:
max_side = int(os.environ.get("CXR_REASON_MAX_SIDE", DEFAULT_MAX_SIDE))
except ValueError:
max_side = DEFAULT_MAX_SIDE
try:
think_chars = int(os.environ.get("CXR_REASON_THINK_CHARS", DEFAULT_THINK_CHARS))
except ValueError:
think_chars = DEFAULT_THINK_CHARS
prompt = (prompt or "").strip() or DEFAULT_PROMPT
tool_name = "analyze_cxr" if quick else "reason_cxr"
# Encode host-side (never returned to the model).
try:
from tools.encode_image import encode_image_for_reasoning
image_b64, image_id = encode_image_for_reasoning(image_path, max_side=max_side)
except Exception as e: # noqa: BLE001
logger.warning("reasoning: could not encode %s — %s", image_path, e)
return {"success": False, "error": f"encode failed: {e}",
"answer": "", "thinking": "", "disclaimer": "",
"image_id": Path(image_path).stem}
try:
from agents.mcp import MCPServerStreamableHttp
except Exception as e: # noqa: BLE001
logger.warning("reasoning: Agents SDK MCP client unavailable — %s", e)
return {"success": False, "error": f"mcp client unavailable: {e}",
"answer": "", "thinking": "", "disclaimer": "", "image_id": image_id}
server = MCPServerStreamableHttp(
name="cxr-reasoning",
params={"url": url},
cache_tools_list=True,
client_session_timeout_seconds=timeout,
max_retry_attempts=2,
)
try:
await server.connect()
try:
result = await server.call_tool(
tool_name,
{"image_b64": image_b64, "image_id": image_id, "prompt": prompt},
)
finally:
await server.cleanup()
except Exception as e: # noqa: BLE001
logger.warning("reasoning: MCP call failed (%s) — %s", url, e)
return {"success": False, "error": f"reasoning call failed: {e}",
"answer": "", "thinking": "", "disclaimer": "", "image_id": image_id}
text = _extract_tool_text(result)
if not text:
return {"success": False, "error": "empty tool output",
"answer": "", "thinking": "", "disclaimer": "", "image_id": image_id}
try:
payload = json.loads(text)
except Exception: # noqa: BLE001
# Not JSON — treat the whole text as the answer.
return {"success": True, "answer": _cap(text, 20000), "thinking": "",
"disclaimer": "", "image_id": image_id}
if isinstance(payload, dict) and payload.get("success") is False:
return {"success": False,
"error": str(payload.get("error") or "reasoning failed"),
"answer": "", "thinking": "", "disclaimer": "", "image_id": image_id}
answer = payload.get("answer") or payload.get("raw_text") or ""
return {
"success": True,
"answer": _cap(answer, 20000),
"thinking": _cap(payload.get("thinking") or "", think_chars),
"disclaimer": _cap(payload.get("disclaimer") or "", 2000),
"image_id": image_id,
} |