fastworkflow 2.16.0__py3-none-any.whl → 2.17.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.
Potentially problematic release.
This version of fastworkflow might be problematic. Click here for more details.
- fastworkflow/_workflows/command_metadata_extraction/_commands/IntentDetection/what_can_i_do.py +12 -7
- fastworkflow/chat_session.py +1 -0
- fastworkflow/command_context_model.py +73 -7
- fastworkflow/command_metadata_api.py +56 -6
- fastworkflow/run/__main__.py +0 -6
- fastworkflow/run_fastapi_mcp/README.md +300 -0
- fastworkflow/run_fastapi_mcp/__init__.py +0 -0
- fastworkflow/run_fastapi_mcp/conversation_store.py +391 -0
- fastworkflow/run_fastapi_mcp/jwt_manager.py +256 -0
- fastworkflow/run_fastapi_mcp/main.py +1206 -0
- fastworkflow/run_fastapi_mcp/mcp_specific.py +103 -0
- fastworkflow/run_fastapi_mcp/redoc_2_standalone_html.py +40 -0
- fastworkflow/run_fastapi_mcp/utils.py +427 -0
- {fastworkflow-2.16.0.dist-info → fastworkflow-2.17.1.dist-info}/METADATA +1 -1
- {fastworkflow-2.16.0.dist-info → fastworkflow-2.17.1.dist-info}/RECORD +18 -10
- {fastworkflow-2.16.0.dist-info → fastworkflow-2.17.1.dist-info}/LICENSE +0 -0
- {fastworkflow-2.16.0.dist-info → fastworkflow-2.17.1.dist-info}/WHEEL +0 -0
- {fastworkflow-2.16.0.dist-info → fastworkflow-2.17.1.dist-info}/entry_points.txt +0 -0
|
@@ -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)
|
|
@@ -0,0 +1,427 @@
|
|
|
1
|
+
import asyncio
|
|
2
|
+
import os
|
|
3
|
+
import queue
|
|
4
|
+
import time
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
from typing import Any, Optional
|
|
7
|
+
|
|
8
|
+
from fastapi import HTTPException, status, Depends
|
|
9
|
+
from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
|
|
10
|
+
from jose import JWTError
|
|
11
|
+
from pydantic import BaseModel, field_validator
|
|
12
|
+
|
|
13
|
+
import fastworkflow
|
|
14
|
+
from fastworkflow.utils.logging import logger
|
|
15
|
+
|
|
16
|
+
from .conversation_store import ConversationStore, restore_history_from_turns
|
|
17
|
+
from .jwt_manager import verify_token
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
# ============================================================================
|
|
21
|
+
# Data Models (aligned with FastWorkflow canonical types)
|
|
22
|
+
# ============================================================================
|
|
23
|
+
|
|
24
|
+
class InitializationRequest(BaseModel):
|
|
25
|
+
"""Request to initialize a FastWorkflow session for a user"""
|
|
26
|
+
user_id: str
|
|
27
|
+
stream_format: Optional[str] = None # "ndjson" | "sse" (default ndjson)
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class TokenResponse(BaseModel):
|
|
31
|
+
"""JWT token pair returned from initialization or token refresh"""
|
|
32
|
+
access_token: str
|
|
33
|
+
refresh_token: str
|
|
34
|
+
token_type: str = "bearer"
|
|
35
|
+
expires_in: int # Access token expiration in seconds
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
class SessionData(BaseModel):
|
|
39
|
+
"""Validated session data extracted from JWT token"""
|
|
40
|
+
user_id: str
|
|
41
|
+
token_type: str # "access" or "refresh"
|
|
42
|
+
issued_at: int # Unix timestamp
|
|
43
|
+
expires_at: int # Unix timestamp
|
|
44
|
+
jti: str # JWT ID (unique token identifier)
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
class InvokeRequest(BaseModel):
|
|
48
|
+
"""
|
|
49
|
+
Request to invoke agent or assistant.
|
|
50
|
+
Requires user_id to be passed in the Authorization header (via JWT token).
|
|
51
|
+
"""
|
|
52
|
+
user_query: str
|
|
53
|
+
timeout_seconds: int = 60
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
class PerformActionRequest(BaseModel):
|
|
57
|
+
"""
|
|
58
|
+
Request to perform a specific action.
|
|
59
|
+
Requires user_id to be passed in the Authorization header (via JWT token).
|
|
60
|
+
"""
|
|
61
|
+
action: dict[str, Any] # Will be converted to fastworkflow.Action
|
|
62
|
+
timeout_seconds: int = 60
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
class PostFeedbackRequest(BaseModel):
|
|
66
|
+
"""
|
|
67
|
+
Request to post feedback on the latest turn.
|
|
68
|
+
Requires user_id to be passed in the Authorization header (via JWT token).
|
|
69
|
+
|
|
70
|
+
Note: binary_or_numeric_score accepts numeric values (float).
|
|
71
|
+
Boolean values (True/False) are automatically converted to 1.0/0.0.
|
|
72
|
+
"""
|
|
73
|
+
binary_or_numeric_score: Optional[float] = None
|
|
74
|
+
nl_feedback: Optional[str] = None
|
|
75
|
+
|
|
76
|
+
@field_validator('nl_feedback')
|
|
77
|
+
@classmethod
|
|
78
|
+
def validate_feedback_presence(cls, v, info):
|
|
79
|
+
"""Ensure at least one feedback field is provided"""
|
|
80
|
+
if v is None and info.data.get('binary_or_numeric_score') is None:
|
|
81
|
+
raise ValueError("At least one of binary_or_numeric_score or nl_feedback must be provided")
|
|
82
|
+
return v
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
class ActivateConversationRequest(BaseModel):
|
|
86
|
+
"""
|
|
87
|
+
Request to activate a conversation by ID.
|
|
88
|
+
Requires user_id to be passed in the Authorization header (via JWT token).
|
|
89
|
+
"""
|
|
90
|
+
conversation_id: int
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
class DumpConversationsRequest(BaseModel):
|
|
94
|
+
"""Admin request to dump all conversations"""
|
|
95
|
+
output_folder: str
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
class GenerateMCPTokenRequest(BaseModel):
|
|
99
|
+
"""Request to generate a long-lived MCP token"""
|
|
100
|
+
user_id: str
|
|
101
|
+
expires_days: int = 365
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
# class CommandOutputWithTraces(BaseModel):
|
|
105
|
+
# """CommandOutput extended with optional traces for HTTP responses"""
|
|
106
|
+
# command_responses: list[dict[str, Any]]
|
|
107
|
+
# workflow_name: str = ""
|
|
108
|
+
# context: str = ""
|
|
109
|
+
# command_name: str = ""
|
|
110
|
+
# command_parameters: str = ""
|
|
111
|
+
# success: bool = True
|
|
112
|
+
# traces: Optional[list[dict[str, Any]]] = None
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
# ============================================================================
|
|
116
|
+
# Helper Functions
|
|
117
|
+
# ============================================================================
|
|
118
|
+
|
|
119
|
+
# Create HTTPBearer security scheme instance
|
|
120
|
+
# This integrates with FastAPI's OpenAPI/Swagger UI to provide the "Authorize" button
|
|
121
|
+
http_bearer = HTTPBearer(
|
|
122
|
+
scheme_name="BearerAuth",
|
|
123
|
+
description="JWT Bearer token obtained from /initialize or /refresh_token endpoint",
|
|
124
|
+
auto_error=True
|
|
125
|
+
)
|
|
126
|
+
|
|
127
|
+
def get_session_from_jwt(
|
|
128
|
+
credentials: HTTPAuthorizationCredentials = Depends(http_bearer)
|
|
129
|
+
) -> SessionData:
|
|
130
|
+
"""
|
|
131
|
+
FastAPI dependency to extract and validate session data from JWT Bearer token.
|
|
132
|
+
|
|
133
|
+
This dependency integrates with FastAPI's security system and Swagger UI:
|
|
134
|
+
- Shows the "Authorize" button in Swagger UI
|
|
135
|
+
- Automatically handles "Bearer " prefix (no need to type it manually)
|
|
136
|
+
- Validates token format and presence
|
|
137
|
+
|
|
138
|
+
Args:
|
|
139
|
+
credentials: HTTPAuthorizationCredentials from the Authorization header.
|
|
140
|
+
FastAPI automatically extracts and validates the Bearer token format.
|
|
141
|
+
|
|
142
|
+
Returns:
|
|
143
|
+
SessionData: Validated session data extracted from the JWT token
|
|
144
|
+
|
|
145
|
+
Raises:
|
|
146
|
+
HTTPException: If the Authorization header is missing, malformed, or contains an invalid/expired token
|
|
147
|
+
|
|
148
|
+
Example:
|
|
149
|
+
Use as a dependency in FastAPI endpoints:
|
|
150
|
+
```python
|
|
151
|
+
@app.post("/endpoint")
|
|
152
|
+
async def endpoint(session: SessionData = Depends(get_session_from_jwt)):
|
|
153
|
+
# Use session.user_id, session.token_type, etc.
|
|
154
|
+
pass
|
|
155
|
+
```
|
|
156
|
+
|
|
157
|
+
HTTP Request Example:
|
|
158
|
+
```bash
|
|
159
|
+
curl -X POST "http://localhost:8000/endpoint" \\
|
|
160
|
+
-H "Authorization: Bearer eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9..." \\
|
|
161
|
+
-H "Content-Type: application/json" \\
|
|
162
|
+
-d '{"data": "value"}'
|
|
163
|
+
```
|
|
164
|
+
|
|
165
|
+
Swagger UI Usage:
|
|
166
|
+
1. Click the "Authorize" button (lock icon)
|
|
167
|
+
2. Enter ONLY your JWT token (without "Bearer " prefix)
|
|
168
|
+
3. Swagger UI automatically adds the "Bearer " prefix
|
|
169
|
+
"""
|
|
170
|
+
# Extract token from credentials (already validated by HTTPBearer)
|
|
171
|
+
token = credentials.credentials
|
|
172
|
+
|
|
173
|
+
# Verify and decode token
|
|
174
|
+
try:
|
|
175
|
+
payload = verify_token(token, expected_type="access")
|
|
176
|
+
|
|
177
|
+
# Extract session data from payload
|
|
178
|
+
return SessionData(
|
|
179
|
+
user_id=payload["sub"],
|
|
180
|
+
token_type=payload["type"],
|
|
181
|
+
issued_at=payload["iat"],
|
|
182
|
+
expires_at=payload["exp"],
|
|
183
|
+
jti=payload["jti"]
|
|
184
|
+
)
|
|
185
|
+
|
|
186
|
+
except JWTError as e:
|
|
187
|
+
raise HTTPException(
|
|
188
|
+
status_code=status.HTTP_401_UNAUTHORIZED,
|
|
189
|
+
detail=f"Invalid or expired token: {str(e)}",
|
|
190
|
+
headers={"WWW-Authenticate": "Bearer"}
|
|
191
|
+
)
|
|
192
|
+
except KeyError as e:
|
|
193
|
+
raise HTTPException(
|
|
194
|
+
status_code=status.HTTP_401_UNAUTHORIZED,
|
|
195
|
+
detail=f"Token missing required claim: {str(e)}",
|
|
196
|
+
headers={"WWW-Authenticate": "Bearer"}
|
|
197
|
+
)
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
async def ensure_user_runtime_exists(
|
|
201
|
+
user_id: str,
|
|
202
|
+
session_manager: 'UserSessionManager',
|
|
203
|
+
workflow_path: str,
|
|
204
|
+
context: Optional[dict] = None,
|
|
205
|
+
startup_command: Optional[str] = None,
|
|
206
|
+
startup_action: Optional['fastworkflow.Action'] = None,
|
|
207
|
+
stream_format: str = "ndjson"
|
|
208
|
+
) -> None:
|
|
209
|
+
"""
|
|
210
|
+
Ensure a user runtime exists in the session manager. If not, create it.
|
|
211
|
+
|
|
212
|
+
This function encapsulates the session creation logic from the initialize endpoint,
|
|
213
|
+
allowing it to be reused across different parts of the application without duplicating code.
|
|
214
|
+
|
|
215
|
+
Args:
|
|
216
|
+
user_id: The user identifier
|
|
217
|
+
session_manager: The UserSessionManager instance
|
|
218
|
+
workflow_path: Path to the workflow directory (validated at server startup)
|
|
219
|
+
context: Optional workflow context dictionary
|
|
220
|
+
startup_command: Optional startup command
|
|
221
|
+
startup_action: Optional startup action
|
|
222
|
+
stream_format: Stream format preference ("ndjson" or "sse", default "ndjson")
|
|
223
|
+
|
|
224
|
+
Raises:
|
|
225
|
+
HTTPException: If session creation fails
|
|
226
|
+
"""
|
|
227
|
+
# Check if user already has an active session
|
|
228
|
+
existing_runtime = await session_manager.get_session(user_id)
|
|
229
|
+
if existing_runtime:
|
|
230
|
+
logger.debug(f"Session for user_id {user_id} already exists, skipping creation")
|
|
231
|
+
return
|
|
232
|
+
|
|
233
|
+
logger.info(f"Creating new session for user_id: {user_id}")
|
|
234
|
+
|
|
235
|
+
# Resolve conversation store base folder from SPEEDDICT_FOLDERNAME/user_conversations
|
|
236
|
+
conv_base_folder = get_userconversations_dir()
|
|
237
|
+
|
|
238
|
+
# Create conversation store for this user
|
|
239
|
+
conversation_store = ConversationStore(user_id, conv_base_folder)
|
|
240
|
+
|
|
241
|
+
# Create ChatSession in agent mode (forced)
|
|
242
|
+
chat_session = fastworkflow.ChatSession(run_as_agent=True)
|
|
243
|
+
|
|
244
|
+
# Restore last conversation if it exists; else start new
|
|
245
|
+
conv_id_to_restore = None
|
|
246
|
+
if conv_id_to_restore := conversation_store.get_last_conversation_id():
|
|
247
|
+
conversation = conversation_store.get_conversation(conv_id_to_restore)
|
|
248
|
+
if not conversation:
|
|
249
|
+
# this means a new conversation was started but not saved
|
|
250
|
+
conv_id_to_restore = conv_id_to_restore-1
|
|
251
|
+
conversation = conversation_store.get_conversation(conv_id_to_restore)
|
|
252
|
+
|
|
253
|
+
if conversation:
|
|
254
|
+
# Restore the conversation history from saved turns
|
|
255
|
+
restored_history = restore_history_from_turns(conversation["turns"])
|
|
256
|
+
chat_session._conversation_history = restored_history
|
|
257
|
+
logger.info(f"Restored conversation {conv_id_to_restore} for user {user_id}")
|
|
258
|
+
else:
|
|
259
|
+
logger.info(f"No conversations available for user {user_id}, starting new")
|
|
260
|
+
conv_id_to_restore = None
|
|
261
|
+
|
|
262
|
+
# Start the workflow
|
|
263
|
+
chat_session.start_workflow(
|
|
264
|
+
workflow_folderpath=workflow_path,
|
|
265
|
+
workflow_context=context,
|
|
266
|
+
startup_command=startup_command,
|
|
267
|
+
startup_action=startup_action,
|
|
268
|
+
keep_alive=True
|
|
269
|
+
)
|
|
270
|
+
|
|
271
|
+
# Create and store user runtime
|
|
272
|
+
await session_manager.create_session(
|
|
273
|
+
user_id=user_id,
|
|
274
|
+
chat_session=chat_session,
|
|
275
|
+
conversation_store=conversation_store,
|
|
276
|
+
active_conversation_id=conv_id_to_restore,
|
|
277
|
+
stream_format=stream_format
|
|
278
|
+
)
|
|
279
|
+
|
|
280
|
+
logger.info(f"Successfully created session for user_id: {user_id}")
|
|
281
|
+
|
|
282
|
+
# Wait for workflow to be ready (background thread sets status to RUNNING)
|
|
283
|
+
import asyncio
|
|
284
|
+
import time
|
|
285
|
+
max_wait = 5 # seconds
|
|
286
|
+
wait_start = time.time()
|
|
287
|
+
from fastworkflow.chat_session import SessionStatus
|
|
288
|
+
while chat_session._status != SessionStatus.RUNNING and time.time() - wait_start < max_wait:
|
|
289
|
+
await asyncio.sleep(0.1)
|
|
290
|
+
|
|
291
|
+
if chat_session._status != SessionStatus.RUNNING:
|
|
292
|
+
logger.warning(f"Workflow not fully started after {max_wait}s, status={chat_session._status}")
|
|
293
|
+
|
|
294
|
+
|
|
295
|
+
def get_userconversations_dir() -> str:
|
|
296
|
+
"""
|
|
297
|
+
Return SPEEDDICT_FOLDERNAME/user_conversations, creating the directory if missing.
|
|
298
|
+
fastworkflow is injected to avoid circular imports and to access get_env_var.
|
|
299
|
+
"""
|
|
300
|
+
speedict_foldername = fastworkflow.get_env_var("SPEEDDICT_FOLDERNAME")
|
|
301
|
+
user_conversations_dir = os.path.join(speedict_foldername, "user_conversations")
|
|
302
|
+
os.makedirs(user_conversations_dir, exist_ok=True)
|
|
303
|
+
return user_conversations_dir
|
|
304
|
+
|
|
305
|
+
|
|
306
|
+
async def wait_for_command_output(
|
|
307
|
+
runtime: 'UserRuntime',
|
|
308
|
+
timeout_seconds: int
|
|
309
|
+
) -> 'fastworkflow.CommandOutput':
|
|
310
|
+
"""Wait for command output from the queue with timeout"""
|
|
311
|
+
start_time = time.time()
|
|
312
|
+
|
|
313
|
+
while time.time() - start_time < timeout_seconds:
|
|
314
|
+
try:
|
|
315
|
+
return runtime.chat_session.command_output_queue.get(timeout=0.5)
|
|
316
|
+
except queue.Empty:
|
|
317
|
+
await asyncio.sleep(0.1)
|
|
318
|
+
continue
|
|
319
|
+
|
|
320
|
+
raise HTTPException(
|
|
321
|
+
status_code=status.HTTP_504_GATEWAY_TIMEOUT,
|
|
322
|
+
detail=f"Command execution timed out after {timeout_seconds} seconds"
|
|
323
|
+
)
|
|
324
|
+
|
|
325
|
+
|
|
326
|
+
def collect_trace_events(runtime: 'UserRuntime') -> list[dict[str, Any]]:
|
|
327
|
+
"""Drain and collect all trace events from the queue"""
|
|
328
|
+
traces = []
|
|
329
|
+
|
|
330
|
+
while True:
|
|
331
|
+
try:
|
|
332
|
+
evt = runtime.chat_session.command_trace_queue.get_nowait()
|
|
333
|
+
traces.append({
|
|
334
|
+
"direction": evt.direction.value if hasattr(evt.direction, 'value') else str(evt.direction),
|
|
335
|
+
"raw_command": evt.raw_command,
|
|
336
|
+
"command_name": evt.command_name,
|
|
337
|
+
"parameters": evt.parameters,
|
|
338
|
+
"response_text": evt.response_text,
|
|
339
|
+
"success": evt.success,
|
|
340
|
+
"timestamp_ms": evt.timestamp_ms
|
|
341
|
+
})
|
|
342
|
+
except queue.Empty:
|
|
343
|
+
break
|
|
344
|
+
|
|
345
|
+
return traces
|
|
346
|
+
|
|
347
|
+
|
|
348
|
+
# ============================================================================
|
|
349
|
+
# Session Management
|
|
350
|
+
# ============================================================================
|
|
351
|
+
|
|
352
|
+
@dataclass
|
|
353
|
+
class UserRuntime:
|
|
354
|
+
"""Per-user runtime state"""
|
|
355
|
+
user_id: str
|
|
356
|
+
active_conversation_id: int
|
|
357
|
+
chat_session: 'fastworkflow.ChatSession'
|
|
358
|
+
lock: asyncio.Lock
|
|
359
|
+
conversation_store: 'ConversationStore'
|
|
360
|
+
stream_format: str = "ndjson" # "ndjson" | "sse"
|
|
361
|
+
|
|
362
|
+
|
|
363
|
+
class UserSessionManager:
|
|
364
|
+
"""Process-wide manager for user sessions"""
|
|
365
|
+
|
|
366
|
+
def __init__(self):
|
|
367
|
+
self._sessions: dict[str, UserRuntime] = {}
|
|
368
|
+
self._lock = asyncio.Lock()
|
|
369
|
+
|
|
370
|
+
async def get_session(self, user_id: str) -> Optional[UserRuntime]:
|
|
371
|
+
"""Get a session by user_id"""
|
|
372
|
+
async with self._lock:
|
|
373
|
+
return self._sessions.get(user_id)
|
|
374
|
+
|
|
375
|
+
async def create_session(
|
|
376
|
+
self,
|
|
377
|
+
user_id: str,
|
|
378
|
+
chat_session: 'fastworkflow.ChatSession',
|
|
379
|
+
conversation_store: 'ConversationStore',
|
|
380
|
+
active_conversation_id: Optional[int] = None,
|
|
381
|
+
stream_format: str = "ndjson"
|
|
382
|
+
) -> UserRuntime:
|
|
383
|
+
"""Create or update a session"""
|
|
384
|
+
async with self._lock:
|
|
385
|
+
runtime = UserRuntime(
|
|
386
|
+
user_id=user_id,
|
|
387
|
+
active_conversation_id=active_conversation_id or 0,
|
|
388
|
+
chat_session=chat_session,
|
|
389
|
+
lock=asyncio.Lock(),
|
|
390
|
+
conversation_store=conversation_store,
|
|
391
|
+
stream_format=stream_format
|
|
392
|
+
)
|
|
393
|
+
self._sessions[user_id] = runtime
|
|
394
|
+
return runtime
|
|
395
|
+
|
|
396
|
+
async def remove_session(self, user_id: str) -> None:
|
|
397
|
+
"""Remove a session"""
|
|
398
|
+
async with self._lock:
|
|
399
|
+
if user_id in self._sessions:
|
|
400
|
+
del self._sessions[user_id]
|
|
401
|
+
|
|
402
|
+
|
|
403
|
+
# ============================================================================
|
|
404
|
+
# Helper Functions
|
|
405
|
+
# ============================================================================
|
|
406
|
+
|
|
407
|
+
def save_conversation_incremental(runtime: UserRuntime, extract_turns_func, logger) -> None:
|
|
408
|
+
"""
|
|
409
|
+
Save conversation turns incrementally after each turn (without generating topic/summary).
|
|
410
|
+
This provides crash protection - all turns except the last will be preserved.
|
|
411
|
+
"""
|
|
412
|
+
# Extract turns from conversation history
|
|
413
|
+
if turns := extract_turns_func(runtime.chat_session.conversation_history):
|
|
414
|
+
# Initialize conversation ID for first conversation if needed
|
|
415
|
+
if runtime.active_conversation_id == 0:
|
|
416
|
+
# This is the first conversation for this session
|
|
417
|
+
# Reserve ID 1 and use it
|
|
418
|
+
runtime.active_conversation_id = runtime.conversation_store.reserve_next_conversation_id()
|
|
419
|
+
logger.debug(f"Initialized first conversation with ID {runtime.active_conversation_id} for user {runtime.user_id}")
|
|
420
|
+
|
|
421
|
+
# Save turns using the active conversation ID
|
|
422
|
+
runtime.conversation_store.save_conversation_turns(
|
|
423
|
+
runtime.active_conversation_id, turns
|
|
424
|
+
)
|
|
425
|
+
logger.debug(f"Incrementally saved {len(turns)} turn(s) to conversation {runtime.active_conversation_id}")
|
|
426
|
+
|
|
427
|
+
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.1
|
|
2
2
|
Name: fastworkflow
|
|
3
|
-
Version: 2.
|
|
3
|
+
Version: 2.17.1
|
|
4
4
|
Summary: A framework for rapidly building large-scale, deterministic, interactive workflows with a fault-tolerant, conversational UX
|
|
5
5
|
License: Apache-2.0
|
|
6
6
|
Keywords: fastworkflow,ai,workflow,llm,openai
|
|
@@ -7,7 +7,7 @@ fastworkflow/_workflows/command_metadata_extraction/_commands/ErrorCorrection/ab
|
|
|
7
7
|
fastworkflow/_workflows/command_metadata_extraction/_commands/ErrorCorrection/you_misunderstood.py,sha256=VHfhwlqc1ceG9P_wL8Fl7dpJA2UlcSrcXhz7zZU9NpA,2517
|
|
8
8
|
fastworkflow/_workflows/command_metadata_extraction/_commands/IntentDetection/go_up.py,sha256=K526OAf5ks95SwqVdRNVxLM_AWDfA1qXbkNYq0dANwg,1889
|
|
9
9
|
fastworkflow/_workflows/command_metadata_extraction/_commands/IntentDetection/reset_context.py,sha256=xvInu6uDw0YRUHVXNyTZphSr75f8QiQgFwDtv7SlE9o,1346
|
|
10
|
-
fastworkflow/_workflows/command_metadata_extraction/_commands/IntentDetection/what_can_i_do.py,sha256=
|
|
10
|
+
fastworkflow/_workflows/command_metadata_extraction/_commands/IntentDetection/what_can_i_do.py,sha256=KEYZ8qJG9j_93ycy2CbRdvoqJZ5FawmwjDyRlXWMNeM,6887
|
|
11
11
|
fastworkflow/_workflows/command_metadata_extraction/_commands/IntentDetection/what_is_current_context.py,sha256=S5RQLr62Q2MnKU85nw4IW_ueAK_FXvhcY9gXajFxujg,1464
|
|
12
12
|
fastworkflow/_workflows/command_metadata_extraction/_commands/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
13
13
|
fastworkflow/_workflows/command_metadata_extraction/_commands/wildcard.py,sha256=Sqpc2hwM-DgmsqiHu3OoOuqo3XnHLkFlmyYCJA8nj_8,7843
|
|
@@ -35,13 +35,13 @@ fastworkflow/build/navigator_stub_generator.py,sha256=_DSvHC6r1xWQiFHtUgPhI51nQf
|
|
|
35
35
|
fastworkflow/build/pydantic_model_generator.py,sha256=oNyoANyUWBpHG-fE3tGL911RNvDzQXjxAm0ssvuXUH4,1854
|
|
36
36
|
fastworkflow/build/utterance_generator.py,sha256=UrtkF0wyAZ1hiFitHX0g8w7Wh-D0leLCrP1aUACSfHo,299
|
|
37
37
|
fastworkflow/cache_matching.py,sha256=OoB--1tO6-O4BKCuCrUbB0CkUr76J62K4VAf6MShi-w,7984
|
|
38
|
-
fastworkflow/chat_session.py,sha256=
|
|
38
|
+
fastworkflow/chat_session.py,sha256=mduwtY27MV5YcabUbaFDXyRalVVNLUpp7ZFNiOV9ewc,31627
|
|
39
39
|
fastworkflow/cli.py,sha256=li9OFT05sxqz4BZJc9byKAeTmomjLfsWMVuy0OiRGSs,18953
|
|
40
|
-
fastworkflow/command_context_model.py,sha256=
|
|
40
|
+
fastworkflow/command_context_model.py,sha256=bQadDB_IH2lc0br46IT07Iej_j2KrAMderiVKqU7gno,15914
|
|
41
41
|
fastworkflow/command_directory.py,sha256=aJ6UQCwevfF11KbcQB2Qz6mQ7Kj91pZtvHmQY6JFnao,29030
|
|
42
42
|
fastworkflow/command_executor.py,sha256=WTSrukv6UDQfWUDSNleIQ1TxwDnAQIKIimh4sQVwnig,8457
|
|
43
43
|
fastworkflow/command_interfaces.py,sha256=PWIKlcp0G8nmYl0vkrg1o6QzJL0pxXkfrn1joqTa0eU,460
|
|
44
|
-
fastworkflow/command_metadata_api.py,sha256=
|
|
44
|
+
fastworkflow/command_metadata_api.py,sha256=J5ltiwOhFRf1fggm8yWziKPrZA3trKt_rkyBtSS0H3I,42233
|
|
45
45
|
fastworkflow/command_routing.py,sha256=R7194pcY0d2VHzmCu9ALacm1UvNuIRIvTn8mLp-EZIM,17219
|
|
46
46
|
fastworkflow/docs/context_modules_prd.txt,sha256=9wvs3LgNoIVXAczo1sXBIV4YmFqVhzC2ja1T3K7FG04,2199
|
|
47
47
|
fastworkflow/examples/extended_workflow_example/README.md,sha256=2O0O4Bg--fwF98YDScnkNCUL3PcH8KpX2p6I1cwNWeg,2864
|
|
@@ -144,7 +144,15 @@ fastworkflow/mcp_server.py,sha256=NxbLSKf2MA4lAHVcm6ZfiVuOjVO6IeV5Iw17wImFbxQ,88
|
|
|
144
144
|
fastworkflow/model_pipeline_training.py,sha256=P_9wrYSfJVSYCTu8VEPkgXJ16eH58LLCK4rCRbRFAVg,46740
|
|
145
145
|
fastworkflow/refine/__main__.py,sha256=bDLpPNMcdp8U4EFnMdjxx1sPDQCZuEJoBURr2KebTng,3398
|
|
146
146
|
fastworkflow/run/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
147
|
-
fastworkflow/run/__main__.py,sha256=
|
|
147
|
+
fastworkflow/run/__main__.py,sha256=XmybAsr1MnCx8tzvhWxtBT7xu2Om3PVZFtABXavPccU,12075
|
|
148
|
+
fastworkflow/run_fastapi_mcp/README.md,sha256=H3XcMNxDcOdGnHNODGe8U7b9s7loiHo-rEZqs-NXzn8,8024
|
|
149
|
+
fastworkflow/run_fastapi_mcp/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
150
|
+
fastworkflow/run_fastapi_mcp/conversation_store.py,sha256=OuS6Yq5noXrNn5jPwld_KueVi53Jlpa0qvF_vLUIbgg,13516
|
|
151
|
+
fastworkflow/run_fastapi_mcp/jwt_manager.py,sha256=fV5cKgYKluBk3l3ZepTSY3MgN0OopyH6oh8G_ncFQ4k,8166
|
|
152
|
+
fastworkflow/run_fastapi_mcp/main.py,sha256=JmgfViu2jpHBYvjx0DJ4QEU3D56CZJXnD1yo_8hzAq4,48148
|
|
153
|
+
fastworkflow/run_fastapi_mcp/mcp_specific.py,sha256=RdOPcPn68KlxNSM9Vb2yeYEDNGoNTcKZq-AC0cd86cw,4506
|
|
154
|
+
fastworkflow/run_fastapi_mcp/redoc_2_standalone_html.py,sha256=oYWn30O-xKX6pVjunCeLupyOM2DbeZ3QgFj-F2LalOE,1191
|
|
155
|
+
fastworkflow/run_fastapi_mcp/utils.py,sha256=GGwX3DEghY4TWntSwvrGvOwaiE_vxh_tCdXT6pA2gP4,15525
|
|
148
156
|
fastworkflow/train/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
149
157
|
fastworkflow/train/__main__.py,sha256=AeGja42d0QhslQkxvDVigIluxxL7DYLdQPXYFOKQ7QA,8536
|
|
150
158
|
fastworkflow/train/generate_synthetic.py,sha256=sTDk-E5ewkS4o-0LJeofiEv4uXGpqdGcFRYKY_Yf36Y,5322
|
|
@@ -168,8 +176,8 @@ fastworkflow/utils/startup_progress.py,sha256=9icSdnpFAxzIq0sUliGpNaH0Efvrt5lDtG
|
|
|
168
176
|
fastworkflow/workflow.py,sha256=37gn7e3ct-gdGw43zS6Ab_ADoJJBO4eJW2PywfUpjEg,18825
|
|
169
177
|
fastworkflow/workflow_agent.py,sha256=-RXoHXH-vrEh6AWC6iYAwwR9CvaRynYuu-KrzOPCJbg,16348
|
|
170
178
|
fastworkflow/workflow_inheritance_model.py,sha256=Pp-qSrQISgPfPjJVUfW84pc7HLmL2evuq0UVIYR51K0,7974
|
|
171
|
-
fastworkflow-2.
|
|
172
|
-
fastworkflow-2.
|
|
173
|
-
fastworkflow-2.
|
|
174
|
-
fastworkflow-2.
|
|
175
|
-
fastworkflow-2.
|
|
179
|
+
fastworkflow-2.17.1.dist-info/LICENSE,sha256=xx0jnfkXJvxRnG63LTGOxlggYnIysveWIZ6H3PNdCrQ,11357
|
|
180
|
+
fastworkflow-2.17.1.dist-info/METADATA,sha256=LynPWVIi_Br9ZTWr7aTnvzhFDnBeSyfC2TxgMJ3y2dg,30336
|
|
181
|
+
fastworkflow-2.17.1.dist-info/WHEEL,sha256=sP946D7jFCHeNz5Iq4fL4Lu-PrWrFsgfLXbbkciIZwg,88
|
|
182
|
+
fastworkflow-2.17.1.dist-info/entry_points.txt,sha256=m8HqoPzCyaZLAx-V5X8MJgw3Lx3GiPDlxNEZ7K-Gb-U,54
|
|
183
|
+
fastworkflow-2.17.1.dist-info/RECORD,,
|
|
File without changes
|
|
File without changes
|
|
File without changes
|