mcp-mesh 0.5.7__py3-none-any.whl → 0.6.1__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- _mcp_mesh/__init__.py +1 -1
- _mcp_mesh/engine/base_injector.py +171 -0
- _mcp_mesh/engine/decorator_registry.py +162 -35
- _mcp_mesh/engine/dependency_injector.py +105 -19
- _mcp_mesh/engine/http_wrapper.py +5 -22
- _mcp_mesh/engine/llm_config.py +45 -0
- _mcp_mesh/engine/llm_errors.py +115 -0
- _mcp_mesh/engine/mesh_llm_agent.py +626 -0
- _mcp_mesh/engine/mesh_llm_agent_injector.py +617 -0
- _mcp_mesh/engine/provider_handlers/__init__.py +20 -0
- _mcp_mesh/engine/provider_handlers/base_provider_handler.py +122 -0
- _mcp_mesh/engine/provider_handlers/claude_handler.py +138 -0
- _mcp_mesh/engine/provider_handlers/generic_handler.py +156 -0
- _mcp_mesh/engine/provider_handlers/openai_handler.py +163 -0
- _mcp_mesh/engine/provider_handlers/provider_handler_registry.py +167 -0
- _mcp_mesh/engine/response_parser.py +205 -0
- _mcp_mesh/engine/signature_analyzer.py +229 -99
- _mcp_mesh/engine/tool_executor.py +169 -0
- _mcp_mesh/engine/tool_schema_builder.py +126 -0
- _mcp_mesh/engine/unified_mcp_proxy.py +14 -12
- _mcp_mesh/generated/.openapi-generator/FILES +7 -0
- _mcp_mesh/generated/.openapi-generator-ignore +0 -1
- _mcp_mesh/generated/mcp_mesh_registry_client/__init__.py +7 -16
- _mcp_mesh/generated/mcp_mesh_registry_client/models/__init__.py +7 -0
- _mcp_mesh/generated/mcp_mesh_registry_client/models/agent_info.py +11 -1
- _mcp_mesh/generated/mcp_mesh_registry_client/models/dependency_resolution_info.py +108 -0
- _mcp_mesh/generated/mcp_mesh_registry_client/models/llm_provider.py +95 -0
- _mcp_mesh/generated/mcp_mesh_registry_client/models/llm_tool_filter.py +111 -0
- _mcp_mesh/generated/mcp_mesh_registry_client/models/llm_tool_filter_filter_inner.py +141 -0
- _mcp_mesh/generated/mcp_mesh_registry_client/models/llm_tool_filter_filter_inner_one_of.py +93 -0
- _mcp_mesh/generated/mcp_mesh_registry_client/models/llm_tool_info.py +103 -0
- _mcp_mesh/generated/mcp_mesh_registry_client/models/mesh_agent_registration.py +1 -1
- _mcp_mesh/generated/mcp_mesh_registry_client/models/mesh_registration_response.py +35 -1
- _mcp_mesh/generated/mcp_mesh_registry_client/models/mesh_tool_registration.py +11 -1
- _mcp_mesh/generated/mcp_mesh_registry_client/models/resolved_llm_provider.py +112 -0
- _mcp_mesh/pipeline/api_heartbeat/api_dependency_resolution.py +9 -72
- _mcp_mesh/pipeline/mcp_heartbeat/fast_heartbeat_check.py +3 -3
- _mcp_mesh/pipeline/mcp_heartbeat/heartbeat_orchestrator.py +35 -10
- _mcp_mesh/pipeline/mcp_heartbeat/heartbeat_pipeline.py +7 -4
- _mcp_mesh/pipeline/mcp_heartbeat/llm_tools_resolution.py +260 -0
- _mcp_mesh/pipeline/mcp_startup/fastapiserver_setup.py +118 -35
- _mcp_mesh/pipeline/mcp_startup/fastmcpserver_discovery.py +8 -1
- _mcp_mesh/pipeline/mcp_startup/heartbeat_preparation.py +111 -5
- _mcp_mesh/pipeline/mcp_startup/server_discovery.py +77 -48
- _mcp_mesh/pipeline/mcp_startup/startup_orchestrator.py +2 -2
- _mcp_mesh/pipeline/mcp_startup/startup_pipeline.py +2 -2
- _mcp_mesh/shared/health_check_cache.py +246 -0
- _mcp_mesh/shared/registry_client_wrapper.py +87 -4
- _mcp_mesh/utils/fastmcp_schema_extractor.py +476 -0
- {mcp_mesh-0.5.7.dist-info → mcp_mesh-0.6.1.dist-info}/METADATA +1 -1
- {mcp_mesh-0.5.7.dist-info → mcp_mesh-0.6.1.dist-info}/RECORD +57 -32
- mesh/__init__.py +18 -4
- mesh/decorators.py +439 -31
- mesh/helpers.py +259 -0
- mesh/types.py +197 -97
- {mcp_mesh-0.5.7.dist-info → mcp_mesh-0.6.1.dist-info}/WHEEL +0 -0
- {mcp_mesh-0.5.7.dist-info → mcp_mesh-0.6.1.dist-info}/licenses/LICENSE +0 -0
mesh/decorators.py
CHANGED
|
@@ -6,7 +6,8 @@ Provides @mesh.tool and @mesh.agent decorators with clean separation of concerns
|
|
|
6
6
|
|
|
7
7
|
import logging
|
|
8
8
|
import uuid
|
|
9
|
-
from collections.abc import Callable
|
|
9
|
+
from collections.abc import Awaitable, Callable
|
|
10
|
+
from functools import wraps
|
|
10
11
|
from typing import Any, TypeVar
|
|
11
12
|
|
|
12
13
|
# Import from _mcp_mesh for registry and runtime integration
|
|
@@ -32,7 +33,7 @@ def _start_uvicorn_immediately(http_host: str, http_port: int):
|
|
|
32
33
|
This prevents the DNS threading conflicts by ensuring uvicorn takes control
|
|
33
34
|
before the script ends and Python enters shutdown state.
|
|
34
35
|
"""
|
|
35
|
-
logger.
|
|
36
|
+
logger.debug(
|
|
36
37
|
f"🎯 IMMEDIATE UVICORN: _start_uvicorn_immediately() called with host={http_host}, port={http_port}"
|
|
37
38
|
)
|
|
38
39
|
|
|
@@ -42,9 +43,9 @@ def _start_uvicorn_immediately(http_host: str, http_port: int):
|
|
|
42
43
|
import time
|
|
43
44
|
|
|
44
45
|
import uvicorn
|
|
45
|
-
from fastapi import FastAPI
|
|
46
|
+
from fastapi import FastAPI, Response
|
|
46
47
|
|
|
47
|
-
logger.
|
|
48
|
+
logger.debug(
|
|
48
49
|
"📦 IMMEDIATE UVICORN: Successfully imported uvicorn, FastAPI, threading, asyncio"
|
|
49
50
|
)
|
|
50
51
|
|
|
@@ -55,11 +56,11 @@ def _start_uvicorn_immediately(http_host: str, http_port: int):
|
|
|
55
56
|
|
|
56
57
|
fastmcp_lifespan = DecoratorRegistry.get_fastmcp_lifespan()
|
|
57
58
|
if fastmcp_lifespan:
|
|
58
|
-
logger.
|
|
59
|
+
logger.debug(
|
|
59
60
|
"✅ IMMEDIATE UVICORN: Found stored FastMCP lifespan, will integrate with FastAPI"
|
|
60
61
|
)
|
|
61
62
|
else:
|
|
62
|
-
logger.
|
|
63
|
+
logger.debug(
|
|
63
64
|
"🔍 IMMEDIATE UVICORN: No FastMCP lifespan found, creating basic FastAPI app"
|
|
64
65
|
)
|
|
65
66
|
except Exception as e:
|
|
@@ -68,20 +69,61 @@ def _start_uvicorn_immediately(http_host: str, http_port: int):
|
|
|
68
69
|
# Create FastAPI app with FastMCP lifespan if available
|
|
69
70
|
if fastmcp_lifespan:
|
|
70
71
|
app = FastAPI(title="MCP Mesh Agent (Starting)", lifespan=fastmcp_lifespan)
|
|
71
|
-
logger.
|
|
72
|
+
logger.debug(
|
|
72
73
|
"📦 IMMEDIATE UVICORN: Created FastAPI app with FastMCP lifespan integration"
|
|
73
74
|
)
|
|
74
75
|
else:
|
|
75
76
|
app = FastAPI(title="MCP Mesh Agent (Starting)")
|
|
76
|
-
logger.
|
|
77
|
+
logger.debug("📦 IMMEDIATE UVICORN: Created minimal FastAPI app")
|
|
78
|
+
|
|
79
|
+
# Add health endpoint that can be updated by pipeline
|
|
80
|
+
# Store health check result in a shared location that can be updated
|
|
81
|
+
health_result = {"status": "starting", "message": "Agent is starting"}
|
|
77
82
|
|
|
78
|
-
# Add basic health endpoint
|
|
79
83
|
@app.get("/health")
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
84
|
+
@app.head("/health")
|
|
85
|
+
async def health(response: Response):
|
|
86
|
+
"""Health check endpoint that supports custom health checks."""
|
|
87
|
+
# Check if a custom health check has been configured
|
|
88
|
+
# The pipeline will update this via DecoratorRegistry
|
|
89
|
+
custom_health = DecoratorRegistry.get_health_check_result()
|
|
90
|
+
health_data = custom_health if custom_health else health_result
|
|
91
|
+
|
|
92
|
+
# Set HTTP status code based on health status
|
|
93
|
+
# K8s expects non-200 status for unhealthy services
|
|
94
|
+
status = health_data.get("status", "starting")
|
|
95
|
+
if status == "healthy":
|
|
96
|
+
response.status_code = 200
|
|
97
|
+
else:
|
|
98
|
+
# Return 503 for unhealthy, degraded, starting, or unknown
|
|
99
|
+
response.status_code = 503
|
|
100
|
+
|
|
101
|
+
return health_data
|
|
102
|
+
|
|
103
|
+
@app.get("/ready")
|
|
104
|
+
@app.head("/ready")
|
|
105
|
+
async def ready(response: Response):
|
|
106
|
+
"""Kubernetes readiness probe - service ready to serve traffic."""
|
|
107
|
+
custom_health = DecoratorRegistry.get_health_check_result()
|
|
108
|
+
health_data = custom_health if custom_health else health_result
|
|
109
|
+
|
|
110
|
+
status = health_data.get("status", "starting")
|
|
111
|
+
if status == "healthy":
|
|
112
|
+
response.status_code = 200
|
|
113
|
+
return {"ready": True, "status": status}
|
|
114
|
+
else:
|
|
115
|
+
response.status_code = 503
|
|
116
|
+
return {
|
|
117
|
+
"ready": False,
|
|
118
|
+
"status": status,
|
|
119
|
+
"reason": f"Service is {status}",
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
@app.get("/livez")
|
|
123
|
+
@app.head("/livez")
|
|
124
|
+
async def livez():
|
|
125
|
+
"""Kubernetes liveness probe - always returns 200 if app is running."""
|
|
126
|
+
return {"alive": True, "message": "Application is running"}
|
|
85
127
|
|
|
86
128
|
@app.get("/immediate-status")
|
|
87
129
|
def immediate_status():
|
|
@@ -90,17 +132,17 @@ def _start_uvicorn_immediately(http_host: str, http_port: int):
|
|
|
90
132
|
"message": "This server was started immediately in decorator",
|
|
91
133
|
}
|
|
92
134
|
|
|
93
|
-
logger.
|
|
135
|
+
logger.debug("📦 IMMEDIATE UVICORN: Added status endpoints")
|
|
94
136
|
|
|
95
137
|
# Determine port (0 means auto-assign)
|
|
96
138
|
port = http_port if http_port > 0 else 8080
|
|
97
139
|
|
|
98
|
-
logger.
|
|
140
|
+
logger.debug(
|
|
99
141
|
f"🚀 IMMEDIATE UVICORN: Starting uvicorn server on {http_host}:{port}"
|
|
100
142
|
)
|
|
101
143
|
|
|
102
144
|
# Use uvicorn.run() for proper signal handling (enables FastAPI lifespan shutdown)
|
|
103
|
-
logger.
|
|
145
|
+
logger.debug(
|
|
104
146
|
"⚡ IMMEDIATE UVICORN: Starting server with uvicorn.run() for proper signal handling"
|
|
105
147
|
)
|
|
106
148
|
|
|
@@ -108,7 +150,7 @@ def _start_uvicorn_immediately(http_host: str, http_port: int):
|
|
|
108
150
|
def run_server():
|
|
109
151
|
"""Run uvicorn server in background thread with proper signal handling."""
|
|
110
152
|
try:
|
|
111
|
-
logger.
|
|
153
|
+
logger.debug(
|
|
112
154
|
f"🌟 IMMEDIATE UVICORN: Starting server on {http_host}:{port}"
|
|
113
155
|
)
|
|
114
156
|
# Use uvicorn.run() instead of Server().run() for proper signal handling
|
|
@@ -130,7 +172,7 @@ def _start_uvicorn_immediately(http_host: str, http_port: int):
|
|
|
130
172
|
thread = threading.Thread(target=run_server, daemon=False)
|
|
131
173
|
thread.start()
|
|
132
174
|
|
|
133
|
-
logger.
|
|
175
|
+
logger.debug(
|
|
134
176
|
"🔒 IMMEDIATE UVICORN: Server thread started (daemon=False) - can handle signals"
|
|
135
177
|
)
|
|
136
178
|
|
|
@@ -151,14 +193,14 @@ def _start_uvicorn_immediately(http_host: str, http_port: int):
|
|
|
151
193
|
|
|
152
194
|
DecoratorRegistry.store_immediate_uvicorn_server(server_info)
|
|
153
195
|
|
|
154
|
-
logger.
|
|
196
|
+
logger.debug(
|
|
155
197
|
"🔄 IMMEDIATE UVICORN: Server reference stored in DecoratorRegistry BEFORE pipeline starts"
|
|
156
198
|
)
|
|
157
199
|
|
|
158
200
|
# Give server a moment to start
|
|
159
201
|
time.sleep(1)
|
|
160
202
|
|
|
161
|
-
logger.
|
|
203
|
+
logger.debug(
|
|
162
204
|
f"✅ IMMEDIATE UVICORN: Uvicorn server running on {http_host}:{port} (daemon thread)"
|
|
163
205
|
)
|
|
164
206
|
|
|
@@ -452,6 +494,8 @@ def agent(
|
|
|
452
494
|
enable_http: bool = True,
|
|
453
495
|
namespace: str = "default",
|
|
454
496
|
health_interval: int = 5, # Will be overridden by centralized defaults
|
|
497
|
+
health_check: Callable[[], Awaitable[Any]] | None = None,
|
|
498
|
+
health_check_ttl: int = 15,
|
|
455
499
|
auto_run: bool = True, # Changed to True by default!
|
|
456
500
|
auto_run_interval: int = 10,
|
|
457
501
|
**kwargs: Any,
|
|
@@ -476,6 +520,10 @@ def agent(
|
|
|
476
520
|
Environment variable: MCP_MESH_NAMESPACE (takes precedence)
|
|
477
521
|
health_interval: Health check interval in seconds (default: 30)
|
|
478
522
|
Environment variable: MCP_MESH_HEALTH_INTERVAL (takes precedence)
|
|
523
|
+
health_check: Optional async function that returns HealthStatus
|
|
524
|
+
Called before heartbeat and on /health endpoint with TTL caching
|
|
525
|
+
health_check_ttl: Cache TTL for health check results in seconds (default: 15)
|
|
526
|
+
Reduces expensive health check calls by caching results
|
|
479
527
|
auto_run: Automatically start service and keep process alive (default: True)
|
|
480
528
|
Environment variable: MCP_MESH_AUTO_RUN (takes precedence)
|
|
481
529
|
auto_run_interval: Keep-alive heartbeat interval in seconds (default: 10)
|
|
@@ -551,6 +599,14 @@ def agent(
|
|
|
551
599
|
if auto_run_interval < 1:
|
|
552
600
|
raise ValueError("auto_run_interval must be at least 1 second")
|
|
553
601
|
|
|
602
|
+
if health_check is not None and not callable(health_check):
|
|
603
|
+
raise ValueError("health_check must be a callable (async function)")
|
|
604
|
+
|
|
605
|
+
if not isinstance(health_check_ttl, int):
|
|
606
|
+
raise ValueError("health_check_ttl must be an integer")
|
|
607
|
+
if health_check_ttl < 1:
|
|
608
|
+
raise ValueError("health_check_ttl must be at least 1 second")
|
|
609
|
+
|
|
554
610
|
# Separate binding host (for uvicorn server) from external host (for registry)
|
|
555
611
|
from _mcp_mesh.shared.host_resolver import HostResolver
|
|
556
612
|
|
|
@@ -622,6 +678,8 @@ def agent(
|
|
|
622
678
|
"enable_http": final_enable_http,
|
|
623
679
|
"namespace": final_namespace,
|
|
624
680
|
"health_interval": final_health_interval,
|
|
681
|
+
"health_check": health_check,
|
|
682
|
+
"health_check_ttl": health_check_ttl,
|
|
625
683
|
"auto_run": final_auto_run,
|
|
626
684
|
"auto_run_interval": final_auto_run_interval,
|
|
627
685
|
"agent_id": agent_id,
|
|
@@ -646,7 +704,7 @@ def agent(
|
|
|
646
704
|
|
|
647
705
|
# Auto-run functionality: start uvicorn immediately to prevent Python shutdown state
|
|
648
706
|
if final_auto_run:
|
|
649
|
-
logger.
|
|
707
|
+
logger.debug(
|
|
650
708
|
f"🚀 AGENT DECORATOR: Auto-run enabled for agent '{name}' - starting uvicorn immediately to prevent shutdown state"
|
|
651
709
|
)
|
|
652
710
|
|
|
@@ -654,7 +712,7 @@ def agent(
|
|
|
654
712
|
fastmcp_lifespan = None
|
|
655
713
|
try:
|
|
656
714
|
# Try to create FastMCP server and extract lifespan
|
|
657
|
-
logger.
|
|
715
|
+
logger.debug(
|
|
658
716
|
"🔍 AGENT DECORATOR: Creating FastMCP server for lifespan extraction"
|
|
659
717
|
)
|
|
660
718
|
|
|
@@ -666,7 +724,7 @@ def agent(
|
|
|
666
724
|
# Look for 'app' attribute (standard FastMCP pattern)
|
|
667
725
|
if hasattr(current_module, "app"):
|
|
668
726
|
fastmcp_server = current_module.app
|
|
669
|
-
logger.
|
|
727
|
+
logger.debug(
|
|
670
728
|
f"🔍 AGENT DECORATOR: Found FastMCP server: {type(fastmcp_server)}"
|
|
671
729
|
)
|
|
672
730
|
|
|
@@ -680,7 +738,7 @@ def agent(
|
|
|
680
738
|
)
|
|
681
739
|
if hasattr(fastmcp_http_app, "lifespan"):
|
|
682
740
|
fastmcp_lifespan = fastmcp_http_app.lifespan
|
|
683
|
-
logger.
|
|
741
|
+
logger.debug(
|
|
684
742
|
"✅ AGENT DECORATOR: Extracted FastMCP lifespan for FastAPI integration"
|
|
685
743
|
)
|
|
686
744
|
|
|
@@ -691,7 +749,7 @@ def agent(
|
|
|
691
749
|
DecoratorRegistry.store_fastmcp_http_app(
|
|
692
750
|
fastmcp_http_app
|
|
693
751
|
)
|
|
694
|
-
logger.
|
|
752
|
+
logger.debug(
|
|
695
753
|
"✅ AGENT DECORATOR: Stored FastMCP HTTP app for proper mounting"
|
|
696
754
|
)
|
|
697
755
|
else:
|
|
@@ -707,7 +765,7 @@ def agent(
|
|
|
707
765
|
"⚠️ AGENT DECORATOR: FastMCP server has no http_app method"
|
|
708
766
|
)
|
|
709
767
|
else:
|
|
710
|
-
logger.
|
|
768
|
+
logger.debug(
|
|
711
769
|
"🔍 AGENT DECORATOR: No FastMCP 'app' found in current module - will handle in pipeline"
|
|
712
770
|
)
|
|
713
771
|
else:
|
|
@@ -720,12 +778,12 @@ def agent(
|
|
|
720
778
|
f"⚠️ AGENT DECORATOR: FastMCP lifespan creation failed: {e}"
|
|
721
779
|
)
|
|
722
780
|
|
|
723
|
-
logger.
|
|
781
|
+
logger.debug(
|
|
724
782
|
f"🎯 AGENT DECORATOR: About to call _start_uvicorn_immediately({binding_host}, {final_http_port})"
|
|
725
783
|
)
|
|
726
784
|
# Start basic uvicorn server immediately to prevent interpreter shutdown
|
|
727
785
|
_start_uvicorn_immediately(binding_host, final_http_port)
|
|
728
|
-
logger.
|
|
786
|
+
logger.debug(
|
|
729
787
|
"✅ AGENT DECORATOR: _start_uvicorn_immediately() call completed"
|
|
730
788
|
)
|
|
731
789
|
|
|
@@ -758,8 +816,8 @@ def route(
|
|
|
758
816
|
async def upload_resume(
|
|
759
817
|
request: Request,
|
|
760
818
|
file: UploadFile = File(...),
|
|
761
|
-
pdf_agent:
|
|
762
|
-
user_service:
|
|
819
|
+
pdf_agent: mesh.McpMeshAgent = None, # Injected by MCP Mesh
|
|
820
|
+
user_service: mesh.McpMeshAgent = None # Injected by MCP Mesh
|
|
763
821
|
):
|
|
764
822
|
result = await pdf_agent.extract_text_from_pdf(file)
|
|
765
823
|
await user_service.update_profile(user_data, result)
|
|
@@ -929,3 +987,353 @@ def set_shutdown_context(context: dict[str, Any]):
|
|
|
929
987
|
"""Set context for graceful shutdown (called from pipeline)."""
|
|
930
988
|
# Delegate to the shared graceful shutdown manager
|
|
931
989
|
set_global_shutdown_context(context)
|
|
990
|
+
|
|
991
|
+
|
|
992
|
+
def llm(
|
|
993
|
+
filter: dict[str, Any] | list[dict[str, Any] | str] | str | None = None,
|
|
994
|
+
*,
|
|
995
|
+
filter_mode: str = "all",
|
|
996
|
+
provider: str | dict[str, Any] = "claude",
|
|
997
|
+
model: str | None = None,
|
|
998
|
+
api_key: str | None = None,
|
|
999
|
+
max_iterations: int = 10,
|
|
1000
|
+
system_prompt: str | None = None,
|
|
1001
|
+
system_prompt_file: str | None = None,
|
|
1002
|
+
context_param: str | None = None,
|
|
1003
|
+
**kwargs: Any,
|
|
1004
|
+
) -> Callable[[T], T]:
|
|
1005
|
+
"""
|
|
1006
|
+
LLM agent decorator with automatic agentic loop.
|
|
1007
|
+
|
|
1008
|
+
This decorator enables LLM agents to automatically access mesh tools via
|
|
1009
|
+
dependency injection. The MeshLlmAgent proxy handles the complete agentic loop:
|
|
1010
|
+
- Tool filtering based on filter parameter
|
|
1011
|
+
- LLM API calls (Claude, OpenAI, etc. via LiteLLM)
|
|
1012
|
+
- Tool execution via MCP proxies
|
|
1013
|
+
- Response parsing to Pydantic models
|
|
1014
|
+
|
|
1015
|
+
Configuration Hierarchy (ENV > Decorator):
|
|
1016
|
+
- MESH_LLM_PROVIDER: Override provider
|
|
1017
|
+
- MESH_LLM_MODEL: Override model
|
|
1018
|
+
- ANTHROPIC_API_KEY: Claude API key
|
|
1019
|
+
- OPENAI_API_KEY: OpenAI API key
|
|
1020
|
+
- MESH_LLM_MAX_ITERATIONS: Override max iterations
|
|
1021
|
+
|
|
1022
|
+
Usage:
|
|
1023
|
+
from pydantic import BaseModel
|
|
1024
|
+
import mesh
|
|
1025
|
+
|
|
1026
|
+
class ChatResponse(BaseModel):
|
|
1027
|
+
answer: str
|
|
1028
|
+
confidence: float
|
|
1029
|
+
|
|
1030
|
+
@mesh.llm(
|
|
1031
|
+
filter={"capability": "document", "tags": ["pdf"]},
|
|
1032
|
+
provider="claude",
|
|
1033
|
+
model="claude-3-5-sonnet-20241022"
|
|
1034
|
+
)
|
|
1035
|
+
@mesh.tool(capability="chat")
|
|
1036
|
+
def chat(message: str, llm: mesh.MeshLlmAgent = None) -> ChatResponse:
|
|
1037
|
+
llm.set_system_prompt("You are a helpful assistant.")
|
|
1038
|
+
return llm(message)
|
|
1039
|
+
|
|
1040
|
+
Args:
|
|
1041
|
+
filter: Tool filter (string, dict, or list of mixed)
|
|
1042
|
+
filter_mode: Filter mode ("all", "best_match", "*")
|
|
1043
|
+
provider: LLM provider (string like "claude" for direct LiteLLM, or dict for mesh delegation)
|
|
1044
|
+
Mesh delegation format: {"capability": "llm", "tags": ["claude"], "version": ">=1.0.0"}
|
|
1045
|
+
When dict: Uses mesh DI to resolve provider agent instead of calling LiteLLM directly
|
|
1046
|
+
model: Model name (can be overridden by MESH_LLM_MODEL) - only used with string provider
|
|
1047
|
+
api_key: API key (can be overridden by provider-specific env vars) - only used with string provider
|
|
1048
|
+
max_iterations: Max agentic loop iterations (can be overridden by MESH_LLM_MAX_ITERATIONS)
|
|
1049
|
+
system_prompt: Default system prompt
|
|
1050
|
+
system_prompt_file: Path to Jinja2 template file
|
|
1051
|
+
**kwargs: Additional configuration
|
|
1052
|
+
|
|
1053
|
+
Returns:
|
|
1054
|
+
Decorated function with MeshLlmAgent injection
|
|
1055
|
+
|
|
1056
|
+
Raises:
|
|
1057
|
+
ValueError: If no MeshLlmAgent parameter found
|
|
1058
|
+
UserWarning: If multiple MeshLlmAgent parameters or non-Pydantic return type
|
|
1059
|
+
"""
|
|
1060
|
+
import inspect
|
|
1061
|
+
import warnings
|
|
1062
|
+
|
|
1063
|
+
def decorator(func: T) -> T:
|
|
1064
|
+
# Step 1: Resolve configuration with hierarchy (ENV > decorator params)
|
|
1065
|
+
# Phase 1: Detect file:// prefix for template files
|
|
1066
|
+
is_template = False
|
|
1067
|
+
template_path = None
|
|
1068
|
+
|
|
1069
|
+
if system_prompt:
|
|
1070
|
+
# Check for file:// prefix
|
|
1071
|
+
if system_prompt.startswith("file://"):
|
|
1072
|
+
is_template = True
|
|
1073
|
+
template_path = system_prompt[7:] # Strip "file://" prefix
|
|
1074
|
+
# Auto-detect .jinja2 or .j2 extension without file:// prefix
|
|
1075
|
+
elif system_prompt.endswith(".jinja2") or system_prompt.endswith(".j2"):
|
|
1076
|
+
is_template = True
|
|
1077
|
+
template_path = system_prompt
|
|
1078
|
+
|
|
1079
|
+
# Backward compatibility: system_prompt_file (deprecated)
|
|
1080
|
+
if system_prompt_file:
|
|
1081
|
+
logger.warning(
|
|
1082
|
+
f"⚠️ @mesh.llm: 'system_prompt_file' parameter is deprecated. "
|
|
1083
|
+
f"Use 'system_prompt=\"file://{system_prompt_file}\"' instead."
|
|
1084
|
+
)
|
|
1085
|
+
if not is_template: # Only use if system_prompt didn't specify a template
|
|
1086
|
+
is_template = True
|
|
1087
|
+
template_path = system_prompt_file
|
|
1088
|
+
|
|
1089
|
+
# Validate context_param usage
|
|
1090
|
+
if context_param and not is_template:
|
|
1091
|
+
logger.warning(
|
|
1092
|
+
f"⚠️ @mesh.llm: 'context_param' specified for function '{func.__name__}' "
|
|
1093
|
+
f"but system_prompt is not a template (no file:// prefix or .jinja2/.j2 extension). "
|
|
1094
|
+
f"Context parameter will be ignored."
|
|
1095
|
+
)
|
|
1096
|
+
|
|
1097
|
+
# Handle provider config: dict (mesh delegation) or string (direct LiteLLM)
|
|
1098
|
+
# If provider is dict, don't allow env var override (explicit mesh delegation)
|
|
1099
|
+
if isinstance(provider, dict):
|
|
1100
|
+
resolved_provider = provider
|
|
1101
|
+
else:
|
|
1102
|
+
resolved_provider = get_config_value(
|
|
1103
|
+
"MESH_LLM_PROVIDER",
|
|
1104
|
+
override=provider,
|
|
1105
|
+
default="claude",
|
|
1106
|
+
rule=ValidationRule.STRING_RULE,
|
|
1107
|
+
)
|
|
1108
|
+
|
|
1109
|
+
resolved_config = {
|
|
1110
|
+
"filter": filter,
|
|
1111
|
+
"filter_mode": get_config_value(
|
|
1112
|
+
"MESH_LLM_FILTER_MODE",
|
|
1113
|
+
override=filter_mode,
|
|
1114
|
+
default="all",
|
|
1115
|
+
rule=ValidationRule.STRING_RULE,
|
|
1116
|
+
),
|
|
1117
|
+
"provider": resolved_provider,
|
|
1118
|
+
"model": get_config_value(
|
|
1119
|
+
"MESH_LLM_MODEL",
|
|
1120
|
+
override=model,
|
|
1121
|
+
default=None,
|
|
1122
|
+
rule=ValidationRule.STRING_RULE,
|
|
1123
|
+
),
|
|
1124
|
+
"api_key": api_key, # Will be resolved from provider-specific env vars later
|
|
1125
|
+
"max_iterations": get_config_value(
|
|
1126
|
+
"MESH_LLM_MAX_ITERATIONS",
|
|
1127
|
+
override=max_iterations,
|
|
1128
|
+
default=10,
|
|
1129
|
+
rule=ValidationRule.NONZERO_RULE,
|
|
1130
|
+
),
|
|
1131
|
+
"system_prompt": system_prompt,
|
|
1132
|
+
"system_prompt_file": system_prompt_file,
|
|
1133
|
+
# Phase 1: Template metadata
|
|
1134
|
+
"is_template": is_template,
|
|
1135
|
+
"template_path": template_path,
|
|
1136
|
+
"context_param": context_param,
|
|
1137
|
+
}
|
|
1138
|
+
resolved_config.update(kwargs)
|
|
1139
|
+
|
|
1140
|
+
# Step 2: Extract output type from return annotation
|
|
1141
|
+
sig = inspect.signature(func)
|
|
1142
|
+
return_annotation = sig.return_annotation
|
|
1143
|
+
|
|
1144
|
+
output_type = None
|
|
1145
|
+
if return_annotation and return_annotation != inspect.Signature.empty:
|
|
1146
|
+
output_type = return_annotation
|
|
1147
|
+
|
|
1148
|
+
# Warn if not a Pydantic model
|
|
1149
|
+
try:
|
|
1150
|
+
from pydantic import BaseModel
|
|
1151
|
+
|
|
1152
|
+
if not (
|
|
1153
|
+
inspect.isclass(output_type) and issubclass(output_type, BaseModel)
|
|
1154
|
+
):
|
|
1155
|
+
warnings.warn(
|
|
1156
|
+
f"Function '{func.__name__}' decorated with @mesh.llm should return a Pydantic BaseModel subclass, "
|
|
1157
|
+
f"got {output_type}. This may cause validation errors at runtime.",
|
|
1158
|
+
UserWarning,
|
|
1159
|
+
stacklevel=2,
|
|
1160
|
+
)
|
|
1161
|
+
except ImportError:
|
|
1162
|
+
pass # Pydantic not available, skip validation
|
|
1163
|
+
|
|
1164
|
+
# Step 3: Find MeshLlmAgent parameter
|
|
1165
|
+
from mesh.types import MeshLlmAgent
|
|
1166
|
+
|
|
1167
|
+
llm_params = []
|
|
1168
|
+
for param_name, param in sig.parameters.items():
|
|
1169
|
+
if param.annotation == MeshLlmAgent or (
|
|
1170
|
+
hasattr(param.annotation, "__origin__")
|
|
1171
|
+
and param.annotation.__origin__ == MeshLlmAgent
|
|
1172
|
+
):
|
|
1173
|
+
llm_params.append(param_name)
|
|
1174
|
+
|
|
1175
|
+
if not llm_params:
|
|
1176
|
+
raise ValueError(
|
|
1177
|
+
f"Function '{func.__name__}' decorated with @mesh.llm must have at least one parameter "
|
|
1178
|
+
f"of type 'mesh.MeshLlmAgent'. Example: def {func.__name__}(..., llm: mesh.MeshLlmAgent = None)"
|
|
1179
|
+
)
|
|
1180
|
+
|
|
1181
|
+
if len(llm_params) > 1:
|
|
1182
|
+
warnings.warn(
|
|
1183
|
+
f"Function '{func.__name__}' has multiple MeshLlmAgent parameters: {llm_params}. "
|
|
1184
|
+
f"Only the first parameter '{llm_params[0]}' will be injected. "
|
|
1185
|
+
f"Additional parameters will be ignored.",
|
|
1186
|
+
UserWarning,
|
|
1187
|
+
stacklevel=2,
|
|
1188
|
+
)
|
|
1189
|
+
|
|
1190
|
+
param_name = llm_params[0]
|
|
1191
|
+
|
|
1192
|
+
# Step 4: Generate unique function ID
|
|
1193
|
+
function_id = f"{func.__name__}_{uuid.uuid4().hex[:8]}"
|
|
1194
|
+
|
|
1195
|
+
# Step 5: Register with DecoratorRegistry
|
|
1196
|
+
DecoratorRegistry.register_mesh_llm(
|
|
1197
|
+
func=func,
|
|
1198
|
+
config=resolved_config,
|
|
1199
|
+
output_type=output_type,
|
|
1200
|
+
param_name=param_name,
|
|
1201
|
+
function_id=function_id,
|
|
1202
|
+
)
|
|
1203
|
+
|
|
1204
|
+
logger.debug(
|
|
1205
|
+
f"@mesh.llm registered: {func.__name__} "
|
|
1206
|
+
f"(provider={resolved_config['provider']}, param={param_name}, filter={filter})"
|
|
1207
|
+
)
|
|
1208
|
+
|
|
1209
|
+
# Step 6: Enhance existing wrapper from @mesh.tool (if present)
|
|
1210
|
+
# or create new wrapper
|
|
1211
|
+
#
|
|
1212
|
+
# This approach:
|
|
1213
|
+
# - Reuses the wrapper created by @mesh.tool (if present)
|
|
1214
|
+
# - Avoids creating multiple wrapper layers
|
|
1215
|
+
# - Ensures FastMCP caches the SAME wrapper instance we update later
|
|
1216
|
+
# - Combines both DI injection and LLM injection in the same wrapper
|
|
1217
|
+
|
|
1218
|
+
# Check if there's an existing wrapper from @mesh.tool
|
|
1219
|
+
mesh_tools = DecoratorRegistry.get_mesh_tools()
|
|
1220
|
+
existing_wrapper = None
|
|
1221
|
+
|
|
1222
|
+
if func.__name__ in mesh_tools:
|
|
1223
|
+
existing_wrapper = mesh_tools[func.__name__].function
|
|
1224
|
+
logger.info(
|
|
1225
|
+
f"🔗 Found existing @mesh.tool wrapper for '{func.__name__}' at {hex(id(existing_wrapper))} - enhancing it"
|
|
1226
|
+
)
|
|
1227
|
+
|
|
1228
|
+
# Trigger debounced processing
|
|
1229
|
+
_trigger_debounced_processing()
|
|
1230
|
+
|
|
1231
|
+
if existing_wrapper:
|
|
1232
|
+
# ENHANCE the existing wrapper with LLM attributes
|
|
1233
|
+
logger.info(
|
|
1234
|
+
f"✨ Enhancing existing wrapper with LLM injection for '{func.__name__}'"
|
|
1235
|
+
)
|
|
1236
|
+
|
|
1237
|
+
# Store the original wrapped function if not already stored
|
|
1238
|
+
if not hasattr(existing_wrapper, "__wrapped__"):
|
|
1239
|
+
existing_wrapper.__wrapped__ = func
|
|
1240
|
+
|
|
1241
|
+
# Store the original call behavior to preserve DI injection
|
|
1242
|
+
original_call = existing_wrapper
|
|
1243
|
+
|
|
1244
|
+
# Create enhanced wrapper that does BOTH DI injection and LLM injection
|
|
1245
|
+
@wraps(func)
|
|
1246
|
+
def combined_injection_wrapper(*args, **kwargs):
|
|
1247
|
+
"""Wrapper that injects both MeshLlmAgent and DI parameters."""
|
|
1248
|
+
# Inject LLM parameter if not provided or if it's None
|
|
1249
|
+
if param_name not in kwargs or kwargs.get(param_name) is None:
|
|
1250
|
+
kwargs[param_name] = combined_injection_wrapper._mesh_llm_agent
|
|
1251
|
+
# Then call the original wrapper (which handles DI injection)
|
|
1252
|
+
return original_call(*args, **kwargs)
|
|
1253
|
+
|
|
1254
|
+
# Add LLM metadata attributes to combined wrapper
|
|
1255
|
+
combined_injection_wrapper._mesh_llm_agent = (
|
|
1256
|
+
None # Will be updated during heartbeat
|
|
1257
|
+
)
|
|
1258
|
+
combined_injection_wrapper._mesh_llm_param_name = param_name
|
|
1259
|
+
combined_injection_wrapper._mesh_llm_function_id = function_id
|
|
1260
|
+
combined_injection_wrapper._mesh_llm_config = resolved_config
|
|
1261
|
+
combined_injection_wrapper._mesh_llm_output_type = output_type
|
|
1262
|
+
combined_injection_wrapper.__wrapped__ = func
|
|
1263
|
+
|
|
1264
|
+
# Create update method for heartbeat that updates the COMBINED wrapper
|
|
1265
|
+
def update_llm_agent(agent):
|
|
1266
|
+
combined_injection_wrapper._mesh_llm_agent = agent
|
|
1267
|
+
logger.info(
|
|
1268
|
+
f"🔄 Updated MeshLlmAgent on combined wrapper for {func.__name__} (function_id={function_id})"
|
|
1269
|
+
)
|
|
1270
|
+
|
|
1271
|
+
combined_injection_wrapper._mesh_update_llm_agent = update_llm_agent
|
|
1272
|
+
|
|
1273
|
+
# Copy any other mesh attributes from existing wrapper
|
|
1274
|
+
for attr in dir(existing_wrapper):
|
|
1275
|
+
if attr.startswith("_mesh_") and not hasattr(
|
|
1276
|
+
combined_injection_wrapper, attr
|
|
1277
|
+
):
|
|
1278
|
+
try:
|
|
1279
|
+
setattr(
|
|
1280
|
+
combined_injection_wrapper,
|
|
1281
|
+
attr,
|
|
1282
|
+
getattr(existing_wrapper, attr),
|
|
1283
|
+
)
|
|
1284
|
+
except AttributeError:
|
|
1285
|
+
pass # Some attributes might not be settable
|
|
1286
|
+
|
|
1287
|
+
# Update DecoratorRegistry with the combined wrapper
|
|
1288
|
+
DecoratorRegistry.update_mesh_llm_function(
|
|
1289
|
+
function_id, combined_injection_wrapper
|
|
1290
|
+
)
|
|
1291
|
+
DecoratorRegistry.update_mesh_tool_function(
|
|
1292
|
+
func.__name__, combined_injection_wrapper
|
|
1293
|
+
)
|
|
1294
|
+
|
|
1295
|
+
logger.info(
|
|
1296
|
+
f"✅ Enhanced wrapper for '{func.__name__}' with combined DI + LLM injection at {hex(id(combined_injection_wrapper))}"
|
|
1297
|
+
)
|
|
1298
|
+
|
|
1299
|
+
# Return the enhanced wrapper
|
|
1300
|
+
return combined_injection_wrapper
|
|
1301
|
+
|
|
1302
|
+
else:
|
|
1303
|
+
# FALLBACK: Create new wrapper if no existing @mesh.tool wrapper found
|
|
1304
|
+
logger.info(
|
|
1305
|
+
f"📝 No existing wrapper found for '{func.__name__}' - creating new LLM wrapper"
|
|
1306
|
+
)
|
|
1307
|
+
|
|
1308
|
+
@wraps(func)
|
|
1309
|
+
def llm_injection_wrapper(*args, **kwargs):
|
|
1310
|
+
"""Wrapper that injects MeshLlmAgent parameter."""
|
|
1311
|
+
# Inject llm parameter if not provided or if it's None
|
|
1312
|
+
if param_name not in kwargs or kwargs.get(param_name) is None:
|
|
1313
|
+
kwargs[param_name] = llm_injection_wrapper._mesh_llm_agent
|
|
1314
|
+
return func(*args, **kwargs)
|
|
1315
|
+
|
|
1316
|
+
# Create update method for heartbeat - updates the wrapper, not func
|
|
1317
|
+
def update_llm_agent(agent):
|
|
1318
|
+
llm_injection_wrapper._mesh_llm_agent = agent
|
|
1319
|
+
logger.info(
|
|
1320
|
+
f"🔄 Updated MeshLlmAgent for {func.__name__} (function_id={function_id})"
|
|
1321
|
+
)
|
|
1322
|
+
|
|
1323
|
+
# Copy all metadata attributes to the wrapper
|
|
1324
|
+
llm_injection_wrapper._mesh_llm_agent = None
|
|
1325
|
+
llm_injection_wrapper._mesh_llm_param_name = param_name
|
|
1326
|
+
llm_injection_wrapper._mesh_llm_function_id = function_id
|
|
1327
|
+
llm_injection_wrapper._mesh_llm_config = resolved_config
|
|
1328
|
+
llm_injection_wrapper._mesh_llm_output_type = output_type
|
|
1329
|
+
llm_injection_wrapper._mesh_update_llm_agent = update_llm_agent
|
|
1330
|
+
|
|
1331
|
+
# Update DecoratorRegistry with the wrapper
|
|
1332
|
+
DecoratorRegistry.update_mesh_llm_function(
|
|
1333
|
+
function_id, llm_injection_wrapper
|
|
1334
|
+
)
|
|
1335
|
+
|
|
1336
|
+
# Return the new wrapper
|
|
1337
|
+
return llm_injection_wrapper
|
|
1338
|
+
|
|
1339
|
+
return decorator
|