Skip to content

Commit 0be9738

Browse files
authored
Merge pull request #22 from Imaging-Plaza/feature/3d-lungs-autocall
autonomous lungs segmentation tool call
2 parents 4b5a65b + 030181a commit 0be9738

16 files changed

Lines changed: 1227 additions & 56 deletions

CHANGELOG.md

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,9 @@ All notable changes to this project will be documented in this file.
44

55
## [Unreleased]
66

7+
### Removed
8+
- **Agent run_example tool**: Removed autonomous tool execution capability from agent. Agent now only recommends tools - all execution requires explicit user approval via approval buttons. This enforces consistent security/UX model where users maintain full control over tool execution. The underlying `gradio_space_tool.py` remains for UI-initiated demo execution.
9+
710
### Added
811
- **New chat-based interface** (`ai_agent chat`) with conversational AI assistant
912
- Chatbot component with rich media rendering (images, files, JSON, code blocks)
@@ -34,6 +37,30 @@ All notable changes to this project will be documented in this file.
3437
- **YAML Model Configuration**: New `config.yaml` file for flexible model configuration supporting OpenAI, EPFL inference server, and any OpenAI-compatible API endpoints.
3538
- **Multi-Model Support**: Can now configure different models for agent (main reasoning & tool selection).
3639
- **Configuration Module**: New `utils/config.py` with Pydantic models for type-safe configuration loading and validation.
40+
- **3D Lungs Segmentation Tool**: New MCP tool (`agent/tools/lungs_segmentation_tool.py`) that integrates with HuggingFace Space (https://qchapp-3d-lungs-segmentation.hf.space/) for 3D U-Net based lung segmentation in CT volumes. Supports DICOM, NIfTI, and TIFF stack inputs with robust file materialization strategy handling multiple Gradio output formats (FileData dict, URL string, local path, server path).
41+
- **Tool Usage Analytics**: Real-time visualization in chat UI showing tool call frequency bar chart and timeline plot. Tracks all tool executions with timestamps and success/failure status. Provides users with transparency about which tools are being used during their session.
42+
- **Downloadable Results Section**: New `download_files` component (gr.File with `file_count="multiple"`) positioned below chatbot for easy access to tool outputs. Files returned by tools (e.g., segmentation masks) are automatically extracted and presented as downloadable items separate from inline previews.
43+
- **Inline Tool Approval Button**: Button-based tool execution approval appears dynamically in chat flow within a styled group box. Shows "🤖 Tool Recommendation" header with contextual button label (e.g., "🚀 Run Lungs Segmentation") only when tool approval is pending. Replaces previous text-based approval pattern for better UX and extensibility.
44+
- **Tool Registry System** (`agent/tools/mcp/registry.py`): Centralized tool registration pattern that eliminates tool-specific UI code
45+
- `ToolConfig` dataclass for declarative tool configuration with field mappings
46+
- `TOOL_REGISTRY` global dictionary for dynamic tool lookup
47+
- `CATALOG_NAME_TO_TOOL` reverse mapping dict to handle catalog name → tool name resolution
48+
- **Catalog name mapping**: Tools can specify `catalog_names` list to map dataset names (e.g., "lungs-segmentation") to internal tool names (e.g., "lungs_segmentation")
49+
- **Clean registration pattern**: Single `ensure_tools_registered()` call in app.py replaces individual tool imports
50+
- Generic extraction functions: `extract_preview()`, `extract_downloads()`, `extract_metadata()`
51+
- Helper functions: `register_tool()`, `get_tool()`, `list_tools()`, `get_tool_display_name()`, `get_tool_icon()`
52+
- `get_tool()` automatically resolves both registry names and catalog names for seamless integration with RAG recommendations
53+
- Supports lazy loading to avoid loading heavy dependencies at import time
54+
- **MCP Tools Subpackage** (`agent/tools/mcp/`): Organized separation of registered imaging tools (MCP protocol) from agent utilities. Base models, registry, and imaging tools (e.g., lungs_segmentation) now in dedicated subpackage for clarity.
55+
- **Base Tool Models** (`agent/tools/mcp/base.py`): Standard Pydantic schemas for tool consistency
56+
- `BaseToolOutput`: Standard fields across all tools (success, error, compute_time_seconds, result_preview, result_origin, metadata_text, notes)
57+
- `BaseToolInput`: Minimal base class for tool inputs
58+
- `ImageToolInput`: Common pattern for image-based tools with image_path and description fields
59+
- **Tool Registration**: Lungs segmentation tool self-registers with complete field mappings
60+
- Preview field: `result_preview`
61+
- Download fields: `result_origin`
62+
- Metadata field: `metadata_text`
63+
- Notes field: `notes`
3764

3865
### Changed
3966
- CLI now supports `ai_agent chat`
@@ -60,6 +87,31 @@ All notable changes to this project will be documented in this file.
6087
- **UI redesign**: File upload moved to dedicated right panel for cleaner workflow
6188
- **Visual hierarchy**: Header with gradient green banner and logo
6289
- **Button styling**: Primary actions use Imaging Plaza green theme colors
90+
- **Tool Approval Workflow**: Replaced text-based approval (responding "yes"/"sure"/"ok" in chat) with explicit button-based approval. Tool execution now requires clicking a dedicated approval button that appears inline in the chat, improving clarity and preventing accidental tool execution.
91+
- **Chat Output Structure**: Extended Gradio component outputs from 6 to 9 values to support new approval box and download files components. All event handlers (`submit_btn.click`, `msg_input.submit`, `approve_tool_btn.click`, `clear_btn.click`) now consistently yield/return all 9 outputs: chatbot history, state, 3 charts, state display, downloads, approval box visibility, and approval button label.
92+
- **Tool Approval Button Position**: Moved approval button from standalone position below input controls to inline group box between chatbot and downloads section. Button now appears as part of "🤖 Tool Recommendation" box with dynamic label showing tool name (e.g., "🚀 Run Lungs Segmentation").
93+
- **Generic Tool Execution** (`ui/handlers.py`): Replaced tool-specific `execute_lungs_segmentation()` with generic `execute_tool_with_approval()`
94+
- Dynamic tool lookup via `get_tool(tool_name)` - NO hardcoded tool names
95+
- Dynamic input construction: `tool_config.input_model(**params)` - works for any Pydantic schema
96+
- Dynamic tool execution: `tool_config.executor(input_obj)` - calls registered executor
97+
- Generic field extraction using registry field mappings - NO tool-specific code
98+
- Works for ANY tool that registers in TOOL_REGISTRY
99+
- Eliminates need for tool-specific if/else chains
100+
- **Architectural benefit**: Adding 70+ tools requires ZERO changes to handlers.py
101+
- **Dynamic Button Labels** (`ui/components.py`): Button text now uses registry helper functions
102+
- `get_tool_display_name()` and `get_tool_icon()` provide consistent labels
103+
- Replaces hardcoded string formatting: `tool_name.replace('_', ' ').title()`
104+
- Button shows proper display name and icon from ToolConfig
105+
- **Lazy Tool Loading** (`agent/tools/__init__.py`): Only export registry, not all tools
106+
- Prevents loading heavy dependencies (nibabel, pydicom) at package import
107+
- Tools imported explicitly where needed (e.g., in `ui/app.py`)
108+
- Added `ensure_tools_registered()` function for explicit bulk loading
109+
- Fixes import hangs caused by eager tool loading
110+
- **Tool Import Location** (`ui/app.py`): Import lungs_segmentation_tool to trigger registration before UI launch
111+
- **LungsSegmentationOutput Schema**: Enhanced with separate `result_origin` (original format file for download), `result_preview` (PNG preview for display), `metadata_text` (file metadata string), and `api_name` fields. Maintains backward compatibility with `result_path` field (now set to preview when available, else origin).
112+
- **Download vs Display Separation**: Tool results now distinguish between files for download (`result_origin` - TIFF/NIfTI/DICOM) and inline display (`result_preview` - PNG). Downloads section shows original format files while chat shows converted previews for better compatibility.
113+
- **HuggingFace Space Client Timeout**: Extended timeout to 300 seconds (5 minutes) via `httpx_kwargs={"timeout": 300.0}` parameter in `_make_gradio_client()` to handle slow Space cold starts and large medical imaging file uploads/downloads without timing out. Includes graceful fallback for older gradio_client versions without `httpx_kwargs` support.
114+
- **Tool Execution Handler**: `execute_tool_with_approval()` in handlers.py now uses `result_preview` for inline images and `result_origin` for downloadable files, ensuring users get both viewable previews in chat and original format files for download.
63115

64116
### Removed
65117
- **VLMToolSelector**: Deleted unused `generator/generator.py` containing VLMToolSelector class. The pydantic-ai agent handles all tool selection directly.
@@ -77,6 +129,10 @@ All notable changes to this project will be documented in this file.
77129
- **Clear Button**: Disabled during processing to prevent race conditions with ongoing requests.
78130
- **Alternative Tool Requests**: All recommended tools are now automatically added to the exclusion list (banlist) and properly passed to the agent through AgentState, ensuring follow-up requests like "I would like another tool" correctly return different tools.
79131
- **History Table**: Follow-up requests (without files) no longer create duplicate history entries. Only primary requests with files are logged to the History table.
132+
- **Duplicate Function Definition**: Removed duplicate `clear_chat()` function definition in `components.py` that was causing syntax errors.
133+
- **Text-Based Tool Approval Logic**: Removed legacy `_is_affirmative()` check in `handlers.py` that was conflicting with new button-based approval system. Tool execution now only triggered by explicit button click, preventing ambiguous user messages from unintentionally executing tools.
134+
- **Gradio Component Compatibility**: Changed `gr.Box` to `gr.Group` for approval button container to ensure compatibility with Gradio 5.42.0 (gr.Box not available in this version).
135+
- **Component Output Count**: Fixed inconsistent yield statements throughout `handle_chat()` generator function - all yields now consistently return 9 values (chatbot, state, 3 charts, state display, downloads, approval box visibility, button label) to match event handler output declarations.
80136

81137
## [0.1.3] - 2025-10-22
82138

config.yaml

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,9 +2,12 @@
22

33
# Default/fallback model (used for CLI and initial startup)
44
agent_model:
5-
name: "gpt-5.1"
6-
base_url: null # null for default OpenAI endpoint
7-
api_key_env: "OPENAI_API_KEY"
5+
# name: "gpt-5.1"
6+
# base_url: null # null for default OpenAI endpoint
7+
# api_key_env: "OPENAI_API_KEY"
8+
name: "openai/gpt-oss-120b"
9+
base_url: "https://inference-rcp.epfl.ch/v1"
10+
api_key_env: "EPFL_API_KEY"
811

912
# Available models for UI dropdown
1013
available_models:

src/ai_agent/agent/agent.py

Lines changed: 14 additions & 38 deletions
Original file line numberDiff line numberDiff line change
@@ -13,12 +13,11 @@
1313
from ai_agent.generator.prompts import get_agent_system_prompt
1414
from ai_agent.generator.schema import ToolSelection, Conversation, ConversationStatus
1515
from ai_agent.utils.config import get_config
16-
from .models import AgentToolSelection, ToolRunLog
16+
from .models import AgentToolSelection, ToolRunLog, UsageStats
1717
from .tools.repo_info_tool import tool_repo_summary, RepoSummaryInput
1818
from ai_agent.agent.utils import coerce_github_url_or_none
1919
from .tools.search_tool import tool_search_tools, SearchToolsInput
2020
from .tools.search_alternative_tool import tool_search_alternative, SearchAlternativeInput
21-
from .tools.gradio_space_tool import tool_run_example, RunExampleInput
2221
from .utils import AgentState, limit_tool_calls, cap_prepare
2322
from ai_agent.utils.image_meta import summarize_image_metadata, detect_ext_token
2423

@@ -216,39 +215,6 @@ async def repo_info(ctx: RunContext[AgentState], url: str, tool_name: str | None
216215
return out.model_dump(mode="python")
217216

218217

219-
@agent.tool(retries=0, prepare=cap_prepare)
220-
@limit_tool_calls("run_example", cap=1)
221-
async def run_example(
222-
ctx: RunContext[AgentState],
223-
tool_name: str,
224-
endpoint_url: str | None = None,
225-
extra_text: str | None = None,
226-
) -> dict:
227-
"""
228-
Run an example / demo for a given tool via its Gradio space.
229-
230-
Thin wrapper around tools.gradio_space_tool.tool_run_example().
231-
"""
232-
out = tool_run_example(
233-
RunExampleInput(
234-
tool_name=tool_name,
235-
endpoint_url=endpoint_url,
236-
extra_text=extra_text,
237-
)
238-
)
239-
ctx.deps.tool_calls.append(
240-
{
241-
"tool": "run_example",
242-
"tool_name": tool_name,
243-
"ran": getattr(out, "ran", False),
244-
"endpoint_url": getattr(out, "endpoint_url", endpoint_url),
245-
"api_name": getattr(out, "api_name", None),
246-
"timestamp": datetime.now().isoformat(),
247-
}
248-
)
249-
return out.model_dump(mode="python")
250-
251-
252218
# ---------------------------------------------------------------------------
253219
# High level entry point: run the agent on (text query + image)
254220
# ---------------------------------------------------------------------------
@@ -387,7 +353,6 @@ def run_agent(
387353
agent_instance.tool(search_tools, retries=2, prepare=cap_prepare)
388354
agent_instance.tool(search_alternative, retries=2, prepare=cap_prepare)
389355
agent_instance.tool(repo_info, retries=2, prepare=cap_prepare)
390-
agent_instance.tool(run_example, retries=0, prepare=cap_prepare)
391356

392357
elif num_choices is not None and num_choices != 3:
393358
log.info(
@@ -403,7 +368,6 @@ def run_agent(
403368
agent_instance.tool(search_tools, retries=2, prepare=cap_prepare)
404369
agent_instance.tool(search_alternative, retries=2, prepare=cap_prepare)
405370
agent_instance.tool(repo_info, retries=2, prepare=cap_prepare)
406-
agent_instance.tool(run_example, retries=0, prepare=cap_prepare)
407371

408372
else:
409373
log.info(f"♻️ Using global agent (model: {effective_model}, num_choices: {effective_num_choices})")
@@ -456,6 +420,7 @@ def run_agent(
456420
# Handle global tool quota limit (UsageLimitExceeded) and other errors gracefully
457421
error_msg = str(e)
458422
log.warning(f"⚠️ Agent execution encountered an error: {error_msg}")
423+
run_result = None # Ensure run_result is defined for usage stats extraction
459424

460425
# Check if this is a usage limit error (global tool quota)
461426
if "UsageLimitExceeded" in str(type(e).__name__) or "tool_calls_limit" in error_msg.lower():
@@ -490,13 +455,24 @@ def run_agent(
490455
)
491456
)
492457

493-
# ---- 8) Wrap into high-level AgentToolSelection ------------------------
458+
# ---- 8) Extract usage statistics if available -------------------------
459+
usage_stats = None
460+
if run_result and hasattr(run_result, "usage") and run_result.usage:
461+
usage = run_result.usage()
462+
usage_stats = UsageStats(
463+
total_tokens=usage.total_tokens,
464+
input_tokens=usage.input_tokens,
465+
output_tokens=usage.output_tokens,
466+
)
467+
468+
# ---- 9) Wrap into high-level AgentToolSelection ------------------------
494469
return AgentToolSelection(
495470
conversation=result.conversation,
496471
choices=result.choices,
497472
explanation=result.explanation,
498473
reason=result.reason,
499474
tool_calls=tool_logs,
475+
usage=usage_stats,
500476
)
501477

502478

src/ai_agent/agent/models.py

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,8 +11,15 @@ class ToolRunLog(BaseModel):
1111
error: Optional[str] = None
1212
timestamp: Optional[str] = None
1313

14+
class UsageStats(BaseModel):
15+
"""Token usage statistics from the agent."""
16+
total_tokens: int = 0
17+
input_tokens: int = 0
18+
output_tokens: int = 0
19+
1420
class AgentToolSelection(ToolSelection):
1521
tool_calls: List[ToolRunLog] = Field(default_factory=list)
22+
usage: Optional[UsageStats] = None
1623

1724
def to_legacy_dict(self) -> Dict[str, Any]:
1825
# Map to legacy pipeline result shape expected by UI (subset)
@@ -22,10 +29,12 @@ def to_legacy_dict(self) -> Dict[str, Any]:
2229
"reason": self.reason,
2330
"explanation": self.explanation,
2431
"tool_calls": [c.model_dump(mode="python") for c in self.tool_calls],
32+
"usage": self.usage.model_dump(mode="python") if self.usage else None,
2533
}
2634

2735
__all__ = [
2836
"AgentToolSelection",
2937
"ToolRunLog",
38+
"UsageStats",
3039
"CandidateDoc",
3140
]
Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
"""Agent tools package."""
2+
3+
# Only export registry - tools will self-register when imported explicitly
4+
from .mcp import (
5+
TOOL_REGISTRY,
6+
get_tool,
7+
register_tool,
8+
list_tools,
9+
ensure_mcp_tools_registered,
10+
)
11+
12+
# Import tools lazily to avoid loading heavy dependencies at package import
13+
# Tools should be imported explicitly where needed, e.g.:
14+
# from ai_agent.agent.tools.mcp.lungs_segmentation_tool import tool_lungs_segmentation
15+
16+
__all__ = [
17+
"TOOL_REGISTRY",
18+
"get_tool",
19+
"register_tool",
20+
"list_tools",
21+
"ensure_tools_registered",
22+
]
23+
24+
25+
def ensure_tools_registered():
26+
"""
27+
Import all tools to trigger their registration.
28+
Call this once at app startup.
29+
"""
30+
from .search_tool import tool_search_tools
31+
from .search_alternative_tool import tool_search_alternative
32+
from .repo_info_tool import tool_repo_summary
33+
from .gradio_space_tool import tool_run_example
34+
35+
# Import MCP tools
36+
ensure_mcp_tools_registered()

src/ai_agent/agent/tools/gradio_space_tool.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
from .utils import get_pipeline
77
from ai_agent.utils.utils import _best_runnable_link
88
from ai_agent.utils.previews import _build_preview_for_vlm
9+
from ai_agent.utils.temp_file_manager import register_temp_file
910
from gradio_client import Client, handle_file
1011
import tempfile
1112
from pathlib import Path
@@ -69,7 +70,7 @@ def _download_to_temp(url: str) -> Optional[str]:
6970
with tempfile.NamedTemporaryFile(delete=False, prefix="demo_result_", suffix=ext) as fd:
7071
fd.write(r.content)
7172
fd.flush()
72-
return fd.name
73+
return register_temp_file(fd.name)
7374
except Exception:
7475
return None
7576

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
"""
2+
MCP (Model Context Protocol) tools package.
3+
4+
This package contains registered imaging tools that require approval
5+
and follow the tool registry pattern.
6+
"""
7+
8+
from .registry import (
9+
TOOL_REGISTRY,
10+
CATALOG_NAME_TO_TOOL,
11+
get_tool,
12+
register_tool,
13+
list_tools,
14+
get_tool_display_name,
15+
get_tool_icon,
16+
extract_preview,
17+
extract_downloads,
18+
extract_metadata,
19+
extract_output_field,
20+
ToolConfig,
21+
)
22+
23+
from .base import BaseToolInput, BaseToolOutput, ImageToolInput
24+
25+
__all__ = [
26+
# Registry
27+
"TOOL_REGISTRY",
28+
"CATALOG_NAME_TO_TOOL",
29+
"get_tool",
30+
"register_tool",
31+
"list_tools",
32+
"get_tool_display_name",
33+
"get_tool_icon",
34+
"extract_preview",
35+
"extract_downloads",
36+
"extract_metadata",
37+
"extract_output_field",
38+
"ToolConfig",
39+
# Base models
40+
"BaseToolInput",
41+
"BaseToolOutput",
42+
"ImageToolInput",
43+
]
44+
45+
46+
def ensure_mcp_tools_registered():
47+
"""
48+
Import all MCP tools to trigger their registration.
49+
Call this once at app startup.
50+
"""
51+
from . import lungs_segmentation_tool

0 commit comments

Comments
 (0)