fastworkflow 2.15.5__py3-none-any.whl → 2.17.13__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.
Files changed (42) hide show
  1. fastworkflow/_workflows/command_metadata_extraction/_commands/ErrorCorrection/you_misunderstood.py +1 -1
  2. fastworkflow/_workflows/command_metadata_extraction/_commands/IntentDetection/what_can_i_do.py +16 -2
  3. fastworkflow/_workflows/command_metadata_extraction/_commands/wildcard.py +27 -570
  4. fastworkflow/_workflows/command_metadata_extraction/intent_detection.py +360 -0
  5. fastworkflow/_workflows/command_metadata_extraction/parameter_extraction.py +411 -0
  6. fastworkflow/chat_session.py +379 -206
  7. fastworkflow/cli.py +80 -165
  8. fastworkflow/command_context_model.py +73 -7
  9. fastworkflow/command_executor.py +14 -5
  10. fastworkflow/command_metadata_api.py +106 -6
  11. fastworkflow/examples/fastworkflow.env +2 -1
  12. fastworkflow/examples/fastworkflow.passwords.env +2 -1
  13. fastworkflow/examples/retail_workflow/_commands/exchange_delivered_order_items.py +32 -3
  14. fastworkflow/examples/retail_workflow/_commands/find_user_id_by_email.py +6 -5
  15. fastworkflow/examples/retail_workflow/_commands/modify_pending_order_items.py +32 -3
  16. fastworkflow/examples/retail_workflow/_commands/return_delivered_order_items.py +13 -2
  17. fastworkflow/examples/retail_workflow/_commands/transfer_to_human_agents.py +1 -1
  18. fastworkflow/intent_clarification_agent.py +131 -0
  19. fastworkflow/mcp_server.py +3 -3
  20. fastworkflow/run/__main__.py +33 -40
  21. fastworkflow/run_fastapi_mcp/README.md +373 -0
  22. fastworkflow/run_fastapi_mcp/__main__.py +1300 -0
  23. fastworkflow/run_fastapi_mcp/conversation_store.py +391 -0
  24. fastworkflow/run_fastapi_mcp/jwt_manager.py +341 -0
  25. fastworkflow/run_fastapi_mcp/mcp_specific.py +103 -0
  26. fastworkflow/run_fastapi_mcp/redoc_2_standalone_html.py +40 -0
  27. fastworkflow/run_fastapi_mcp/utils.py +517 -0
  28. fastworkflow/train/__main__.py +1 -1
  29. fastworkflow/utils/chat_adapter.py +99 -0
  30. fastworkflow/utils/python_utils.py +4 -4
  31. fastworkflow/utils/react.py +258 -0
  32. fastworkflow/utils/signatures.py +338 -139
  33. fastworkflow/workflow.py +1 -5
  34. fastworkflow/workflow_agent.py +185 -133
  35. {fastworkflow-2.15.5.dist-info → fastworkflow-2.17.13.dist-info}/METADATA +16 -18
  36. {fastworkflow-2.15.5.dist-info → fastworkflow-2.17.13.dist-info}/RECORD +40 -30
  37. fastworkflow/run_agent/__main__.py +0 -294
  38. fastworkflow/run_agent/agent_module.py +0 -194
  39. /fastworkflow/{run_agent → run_fastapi_mcp}/__init__.py +0 -0
  40. {fastworkflow-2.15.5.dist-info → fastworkflow-2.17.13.dist-info}/LICENSE +0 -0
  41. {fastworkflow-2.15.5.dist-info → fastworkflow-2.17.13.dist-info}/WHEEL +0 -0
  42. {fastworkflow-2.15.5.dist-info → fastworkflow-2.17.13.dist-info}/entry_points.txt +0 -0
@@ -0,0 +1,341 @@
1
+ """
2
+ JWT Token Management for FastWorkflow FastAPI Service
3
+
4
+ Handles RSA key pair generation, JWT token creation and verification.
5
+ Keys are stored in ./jwt_keys/ directory.
6
+ """
7
+
8
+ import os
9
+ from datetime import datetime, timedelta, timezone
10
+ from typing import Optional
11
+
12
+ from jose import JWTError, jwt
13
+ from jose.constants import ALGORITHMS
14
+ from cryptography.hazmat.primitives import serialization
15
+ from cryptography.hazmat.primitives.asymmetric import rsa
16
+ from cryptography.hazmat.backends import default_backend
17
+
18
+ from fastworkflow.utils.logging import logger
19
+
20
+
21
+ # JWT Configuration (can be made configurable via env vars)
22
+ JWT_ALGORITHM = ALGORITHMS.RS256
23
+ JWT_ACCESS_TOKEN_EXPIRE_MINUTES = 60 # 1 hour
24
+ JWT_REFRESH_TOKEN_EXPIRE_DAYS = 30 # 30 days
25
+ JWT_ISSUER = "fastworkflow-api"
26
+ JWT_AUDIENCE = "fastworkflow-client"
27
+
28
+ # Key storage location (relative to project root)
29
+ KEYS_DIR = "./jwt_keys"
30
+ PRIVATE_KEY_PATH = os.path.join(KEYS_DIR, "private_key.pem")
31
+ PUBLIC_KEY_PATH = os.path.join(KEYS_DIR, "public_key.pem")
32
+
33
+ # In-memory cache for loaded keys
34
+ _private_key: Optional[str] = None
35
+ _public_key: Optional[str] = None
36
+
37
+ # Flag to control JWT verification behavior (set from CLI)
38
+ EXPECT_ENCRYPTED_JWT = True # Default to secure mode
39
+
40
+
41
+ def ensure_keys_directory() -> None:
42
+ """Create jwt_keys directory if it doesn't exist."""
43
+ os.makedirs(KEYS_DIR, exist_ok=True)
44
+ logger.info(f"JWT keys directory ensured at: {KEYS_DIR}")
45
+
46
+
47
+ def generate_rsa_key_pair() -> tuple[str, str]:
48
+ """
49
+ Generate a new RSA 2048-bit key pair.
50
+
51
+ Returns:
52
+ tuple[str, str]: (private_key_pem, public_key_pem)
53
+ """
54
+ logger.info("Generating new RSA 2048-bit key pair for JWT...")
55
+
56
+ # Generate private key
57
+ private_key = rsa.generate_private_key(
58
+ public_exponent=65537,
59
+ key_size=2048,
60
+ backend=default_backend()
61
+ )
62
+
63
+ # Serialize private key to PEM format
64
+ private_pem = private_key.private_bytes(
65
+ encoding=serialization.Encoding.PEM,
66
+ format=serialization.PrivateFormat.PKCS8,
67
+ encryption_algorithm=serialization.NoEncryption()
68
+ ).decode('utf-8')
69
+
70
+ # Extract public key and serialize to PEM format
71
+ public_key = private_key.public_key()
72
+ public_pem = public_key.public_bytes(
73
+ encoding=serialization.Encoding.PEM,
74
+ format=serialization.PublicFormat.SubjectPublicKeyInfo
75
+ ).decode('utf-8')
76
+
77
+ logger.info("RSA key pair generated successfully")
78
+ return private_pem, public_pem
79
+
80
+
81
+ def save_keys_to_disk(private_pem: str, public_pem: str) -> None:
82
+ """
83
+ Save RSA keys to disk with appropriate permissions.
84
+
85
+ Args:
86
+ private_pem: Private key in PEM format
87
+ public_pem: Public key in PEM format
88
+ """
89
+ ensure_keys_directory()
90
+
91
+ # Save private key (mode 600 for security)
92
+ with open(PRIVATE_KEY_PATH, 'w') as f:
93
+ f.write(private_pem)
94
+ os.chmod(PRIVATE_KEY_PATH, 0o600)
95
+ logger.info(f"Private key saved to: {PRIVATE_KEY_PATH} (mode 600)")
96
+
97
+ # Save public key (mode 644 is fine)
98
+ with open(PUBLIC_KEY_PATH, 'w') as f:
99
+ f.write(public_pem)
100
+ os.chmod(PUBLIC_KEY_PATH, 0o644)
101
+ logger.info(f"Public key saved to: {PUBLIC_KEY_PATH} (mode 644)")
102
+
103
+
104
+ def load_or_generate_keys() -> tuple[str, str]:
105
+ """
106
+ Load existing RSA keys from disk, or generate new ones if they don't exist.
107
+ Caches keys in memory for performance.
108
+
109
+ Returns:
110
+ tuple[str, str]: (private_key_pem, public_key_pem)
111
+ """
112
+ global _private_key, _public_key
113
+
114
+ # Return cached keys if available
115
+ if _private_key and _public_key:
116
+ return _private_key, _public_key
117
+
118
+ # Try to load existing keys
119
+ if os.path.exists(PRIVATE_KEY_PATH) and os.path.exists(PUBLIC_KEY_PATH):
120
+ logger.info("Loading existing RSA keys from disk...")
121
+ with open(PRIVATE_KEY_PATH, 'r') as f:
122
+ _private_key = f.read()
123
+ with open(PUBLIC_KEY_PATH, 'r') as f:
124
+ _public_key = f.read()
125
+ logger.info("RSA keys loaded successfully")
126
+ else:
127
+ # Generate and save new keys
128
+ logger.info("No existing RSA keys found, generating new ones...")
129
+ _private_key, _public_key = generate_rsa_key_pair()
130
+ save_keys_to_disk(_private_key, _public_key)
131
+
132
+ return _private_key, _public_key
133
+
134
+
135
+ def set_jwt_verification_mode(expect_encrypted: bool) -> None:
136
+ """
137
+ Configure JWT verification mode for trusted network scenarios.
138
+
139
+ When expect_encrypted=False, JWT tokens are decoded without signature verification.
140
+ This mode is ONLY suitable for trusted internal networks where JWT is used for
141
+ data transport rather than security.
142
+
143
+ WARNING: Disabling signature verification allows any client to forge tokens.
144
+ Only use in controlled environments.
145
+ """
146
+ global EXPECT_ENCRYPTED_JWT
147
+ EXPECT_ENCRYPTED_JWT = expect_encrypted
148
+ if not expect_encrypted:
149
+ logger.warning(
150
+ "JWT signature verification DISABLED. "
151
+ "Tokens will be accepted without cryptographic validation. "
152
+ "Only use in trusted internal networks."
153
+ )
154
+
155
+
156
+ def create_access_token(channel_id: str, user_id: Optional[str] = None, expires_days: int | None = None) -> str:
157
+ """
158
+ Create a JWT access token for a user.
159
+
160
+ Behavior depends on EXPECT_ENCRYPTED_JWT flag:
161
+ - If True: Creates a signed token using RSA algorithm
162
+ - If False: Creates an unsigned token for trusted network use
163
+
164
+ Args:
165
+ channel_id: Channel identifier (required)
166
+ user_id: User identifier (optional, will be included as uid claim if provided)
167
+ expires_days: Optional custom expiration in days. If None, uses JWT_ACCESS_TOKEN_EXPIRE_MINUTES (default 60 minutes).
168
+
169
+ Returns:
170
+ str: Encoded JWT access token (signed or unsigned based on EXPECT_ENCRYPTED_JWT)
171
+ """
172
+ now = datetime.now(timezone.utc)
173
+ if expires_days is not None:
174
+ expire = now + timedelta(days=expires_days)
175
+ else:
176
+ expire = now + timedelta(minutes=JWT_ACCESS_TOKEN_EXPIRE_MINUTES)
177
+
178
+ # JWT claims
179
+ payload = {
180
+ "sub": channel_id, # Subject: the channel identifier
181
+ "iat": int(now.timestamp()), # Issued at
182
+ "exp": int(expire.timestamp()), # Expiration time
183
+ "jti": f"{channel_id}_{int(now.timestamp())}", # JWT ID (unique identifier)
184
+ "type": "access", # Token type
185
+ "iss": JWT_ISSUER, # Issuer
186
+ "aud": JWT_AUDIENCE # Audience
187
+ }
188
+
189
+ # Add optional user_id claim
190
+ if user_id is not None:
191
+ payload["uid"] = user_id
192
+
193
+ if EXPECT_ENCRYPTED_JWT:
194
+ # Secure mode: create signed token
195
+ private_key, _ = load_or_generate_keys()
196
+ token = jwt.encode(payload, private_key, algorithm=JWT_ALGORITHM)
197
+ logger.debug(f"Created signed access token for channel_id: {channel_id}, user_id: {user_id}, expires: {expire.isoformat()}")
198
+ else:
199
+ # Trusted network mode: create unsigned token using HS256 with empty key
200
+ # This creates a JWT that can be decoded without verification
201
+ token = jwt.encode(payload, "", algorithm="HS256")
202
+ logger.debug(f"Created unsigned access token for channel_id: {channel_id}, user_id: {user_id}, expires: {expire.isoformat()}")
203
+
204
+ return token
205
+
206
+
207
+ def create_refresh_token(channel_id: str, user_id: Optional[str] = None) -> str:
208
+ """
209
+ Create a JWT refresh token for a user.
210
+
211
+ Behavior depends on EXPECT_ENCRYPTED_JWT flag:
212
+ - If True: Creates a signed token using RSA algorithm
213
+ - If False: Creates an unsigned token for trusted network use
214
+
215
+ Args:
216
+ channel_id: Channel identifier (required)
217
+ user_id: User identifier (optional, will be included as uid claim if provided)
218
+
219
+ Returns:
220
+ str: Encoded JWT refresh token (signed or unsigned based on EXPECT_ENCRYPTED_JWT)
221
+ """
222
+ now = datetime.now(timezone.utc)
223
+ expire = now + timedelta(days=JWT_REFRESH_TOKEN_EXPIRE_DAYS)
224
+
225
+ # JWT claims
226
+ payload = {
227
+ "sub": channel_id, # Subject: the channel identifier
228
+ "iat": int(now.timestamp()), # Issued at
229
+ "exp": int(expire.timestamp()), # Expiration time
230
+ "jti": f"{channel_id}_{int(now.timestamp())}_refresh", # JWT ID (unique identifier)
231
+ "type": "refresh", # Token type
232
+ "iss": JWT_ISSUER, # Issuer
233
+ "aud": JWT_AUDIENCE # Audience
234
+ }
235
+
236
+ # Add optional user_id claim
237
+ if user_id is not None:
238
+ payload["uid"] = user_id
239
+
240
+ if EXPECT_ENCRYPTED_JWT:
241
+ # Secure mode: create signed token
242
+ private_key, _ = load_or_generate_keys()
243
+ token = jwt.encode(payload, private_key, algorithm=JWT_ALGORITHM)
244
+ logger.debug(f"Created signed refresh token for channel_id: {channel_id}, user_id: {user_id}, expires: {expire.isoformat()}")
245
+ else:
246
+ # Trusted network mode: create unsigned token using HS256 with empty key
247
+ # This creates a JWT that can be decoded without verification
248
+ token = jwt.encode(payload, "", algorithm="HS256")
249
+ logger.debug(f"Created unsigned refresh token for channel_id: {channel_id}, user_id: {user_id}, expires: {expire.isoformat()}")
250
+
251
+ return token
252
+
253
+
254
+ def verify_token(token: str, expected_type: str = "access") -> dict:
255
+ # sourcery skip: extract-duplicate-method
256
+ """
257
+ Verify and decode a JWT token.
258
+
259
+ Behavior depends on EXPECT_ENCRYPTED_JWT flag:
260
+ - If True (default): Full cryptographic verification with signature check
261
+ - If False: Extract payload without verification (trusted network mode)
262
+
263
+ Args:
264
+ token: JWT token string
265
+ expected_type: Expected token type ("access" or "refresh")
266
+
267
+ Returns:
268
+ dict: Decoded token payload
269
+
270
+ Raises:
271
+ JWTError: If token is invalid, expired, or type mismatch
272
+ """
273
+ if not EXPECT_ENCRYPTED_JWT:
274
+ # Trusted network mode: decode without verification (accepts both unsigned and signed tokens)
275
+ try:
276
+ # Use unverified decoding - works for any JWT regardless of algorithm or signing
277
+ payload = jwt.get_unverified_claims(token)
278
+ except Exception as e:
279
+ logger.warning(f"Token decoding failed: {e}")
280
+ raise JWTError(f"Failed to decode token: {e}") from e
281
+
282
+ # Manually check expiration even in unverified mode
283
+ if exp_timestamp := payload.get("exp"):
284
+ import time
285
+ current_time = int(time.time())
286
+ if exp_timestamp < current_time:
287
+ logger.warning(f"Token expired: exp={exp_timestamp}, now={current_time}")
288
+ raise JWTError("Token has expired")
289
+
290
+ # Validate token type for consistency (outside try-except to allow JWTError to propagate)
291
+ if payload.get("type") != expected_type:
292
+ raise JWTError(f"Invalid token type: expected {expected_type}, got {payload.get('type')}")
293
+
294
+ logger.debug(f"Token decoded (unverified mode): channel_id={payload.get('sub')}, type={expected_type}")
295
+ return payload
296
+
297
+ # Standard mode: full verification (existing code)
298
+ _, public_key = load_or_generate_keys()
299
+
300
+ try:
301
+ # Decode and verify token
302
+ payload = jwt.decode(
303
+ token,
304
+ public_key,
305
+ algorithms=[JWT_ALGORITHM],
306
+ issuer=JWT_ISSUER,
307
+ audience=JWT_AUDIENCE
308
+ )
309
+
310
+ # Verify token type
311
+ if payload.get("type") != expected_type:
312
+ raise JWTError(f"Invalid token type: expected {expected_type}, got {payload.get('type')}")
313
+
314
+ logger.debug(f"Token verified successfully: channel_id={payload.get('sub')}, type={expected_type}")
315
+ return payload
316
+
317
+ except JWTError as e:
318
+ logger.warning(f"Token verification failed: {e}")
319
+ raise
320
+
321
+
322
+ def get_token_expiry(token: str) -> Optional[datetime]:
323
+ """
324
+ Get the expiration time of a JWT token without full verification.
325
+ Useful for debugging/logging.
326
+
327
+ Args:
328
+ token: JWT token string
329
+
330
+ Returns:
331
+ datetime: Expiration time in UTC, or None if token is invalid
332
+ """
333
+ try:
334
+ # Decode without verification (just to inspect claims)
335
+ payload = jwt.get_unverified_claims(token)
336
+ if exp_timestamp := payload.get("exp"):
337
+ return datetime.fromtimestamp(exp_timestamp, tz=timezone.utc)
338
+ except Exception as e:
339
+ logger.debug(f"Failed to get token expiry: {e}")
340
+ return None
341
+
@@ -0,0 +1,103 @@
1
+ # pyright: reportUnusedFunction=false
2
+
3
+ from fastapi_mcp import FastApiMCP
4
+
5
+ def setup_mcp(
6
+ app,
7
+ session_manager,
8
+ ):
9
+ """Mount MCP to automatically convert FastAPI endpoints to MCP tools.
10
+
11
+ FastAPI endpoints are automatically exposed as MCP tools, except those in the exclude list.
12
+
13
+ Key exposed tools:
14
+ - invoke_agent: Streaming agent invocation with NDJSON/SSE support (from /invoke_agent_stream)
15
+ - invoke_assistant: Assistant mode (deterministic execution)
16
+ - new_conversation, get_all_conversations, post_feedback, activate_conversation
17
+
18
+ MCP Client Setup:
19
+ - MCP clients use pre-configured long-lived access tokens (generated via /admin/generate_mcp_token)
20
+ - Tokens are added to the MCP client configuration, not obtained via tool calls
21
+ - No need for initialize or refresh_token tools in MCP context
22
+
23
+ Note: Prompt registration (format-command, clarify-params) is commented out
24
+ as fastapi-mcp 0.4.0 does not support custom prompts.
25
+ """
26
+
27
+ # =========================================================================
28
+ # Mount MCP (FastApiMCP will scan and find all FastAPI endpoints)
29
+ # =========================================================================
30
+
31
+ # Exclude endpoints that should not be exposed as MCP tools:
32
+ # - root: HTML homepage endpoint
33
+ # - dump_all_conversations: Admin-only endpoint for dumping all user conversations
34
+ # - generate_mcp_token: Admin-only endpoint for generating long-lived MCP tokens
35
+ # - rest_initialize: Regular initialization (MCP clients use pre-configured tokens, don't need to initialize)
36
+ # - perform_action: Low-level action execution (use invoke_agent/invoke_assistant instead)
37
+ # - rest_invoke_agent: Non-streaming version (use "invoke_agent" streaming endpoint instead)
38
+ # - refresh_token: JWT token refresh (not needed for MCP since MCP uses long-lived tokens)
39
+ #
40
+ # Exposed MCP tools:
41
+ # - invoke_agent (operation_id) → /invoke_agent_stream endpoint (streaming with NDJSON/SSE support)
42
+ # - invoke_assistant: Assistant mode (deterministic execution)
43
+ # - new_conversation, get_all_conversations, post_feedback, activate_conversation
44
+ #
45
+ # Note: MCP clients are configured with long-lived access tokens generated via /admin/generate_mcp_token
46
+ mcp = FastApiMCP(
47
+ app,
48
+ exclude_operations=[
49
+ "root",
50
+ "dump_all_conversations",
51
+ "generate_mcp_token",
52
+ "rest_initialize",
53
+ "perform_action",
54
+ "rest_invoke_agent",
55
+ "refresh_token"
56
+ ]
57
+ )
58
+ mcp.mount_http()
59
+
60
+ # Note: Prompt registration is not supported in fastapi-mcp 0.4.0
61
+ # The library automatically converts FastAPI endpoints to MCP tools,
62
+ # but does not provide a way to register custom prompts.
63
+ # Prompts may be added in a future version or via manual MCP server implementation.
64
+
65
+ # TODO: Re-enable when fastapi-mcp supports prompts or implement custom prompt handler
66
+ # # Prompts
67
+ # mcp.add_prompt(
68
+ # name="format-command",
69
+ # description="Given command metadata and a user intent, format a single executable command with XML-tagged parameters.",
70
+ # arguments=[{"name": "intent", "required": True}, {"name": "metadata", "required": True}],
71
+ # handler=lambda intent, metadata: [
72
+ # {
73
+ # "role": "user",
74
+ # "content": {
75
+ # "type": "text",
76
+ # "text": (
77
+ # f"Intent: {intent}\n\nMetadata:\n{metadata}\n\n"
78
+ # "Format a single command: command_name <param>value</param> ..."
79
+ # ),
80
+ # },
81
+ # }
82
+ # ],
83
+ # )
84
+ #
85
+ # mcp.add_prompt(
86
+ # name="clarify-params",
87
+ # description="Compose a concise clarification question for missing parameters using the provided metadata.",
88
+ # arguments=[{"name": "error_message", "required": True}, {"name": "metadata", "required": True}],
89
+ # handler=lambda error_message, metadata: [
90
+ # {
91
+ # "role": "user",
92
+ # "content": {
93
+ # "type": "text",
94
+ # "text": (
95
+ # f"{error_message}\n\nMetadata:\n{metadata}\n\n"
96
+ # "Ask one short question to request the missing parameters."
97
+ # ),
98
+ # },
99
+ # }
100
+ # ],
101
+ # )
102
+
103
+
@@ -0,0 +1,40 @@
1
+ """
2
+ Script to export the ReDoc documentation page into a standalone HTML file.
3
+ Created by https://github.com/pawamoy on https://github.com/Redocly/redoc/issues/726#issuecomment-645414239
4
+ """
5
+
6
+
7
+ import json
8
+
9
+ from services.run_fastapi.main import app
10
+
11
+ HTML_TEMPLATE = """<!DOCTYPE html>
12
+ <html>
13
+ <head>
14
+ <meta http-equiv="content-type" content="text/html; charset=UTF-8">
15
+ <title>My Project - ReDoc</title>
16
+ <meta charset="utf-8">
17
+ <meta name="viewport" content="width=device-width, initial-scale=1">
18
+ <link rel="shortcut icon" href="https://fastapi.tiangolo.com/img/favicon.png">
19
+ <style>
20
+ body {
21
+ margin: 0;
22
+ padding: 0;
23
+ }
24
+ </style>
25
+ <style data-styled="" data-styled-version="4.4.1"></style>
26
+ </head>
27
+ <body>
28
+ <div id="redoc-container"></div>
29
+ <script src="https://cdn.jsdelivr.net/npm/redoc/bundles/redoc.standalone.js"> </script>
30
+ <script>
31
+ var spec = %s;
32
+ Redoc.init(spec, {}, document.getElementById("redoc-container"));
33
+ </script>
34
+ </body>
35
+ </html>
36
+ """
37
+
38
+ if __name__ == "__main__":
39
+ with open("redoc.html", "w", encoding="utf-8") as fd:
40
+ print(HTML_TEMPLATE % json.dumps(app.openapi()), file=fd)