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.
- fastworkflow/_workflows/command_metadata_extraction/_commands/ErrorCorrection/you_misunderstood.py +1 -1
- fastworkflow/_workflows/command_metadata_extraction/_commands/IntentDetection/what_can_i_do.py +16 -2
- fastworkflow/_workflows/command_metadata_extraction/_commands/wildcard.py +27 -570
- fastworkflow/_workflows/command_metadata_extraction/intent_detection.py +360 -0
- fastworkflow/_workflows/command_metadata_extraction/parameter_extraction.py +411 -0
- fastworkflow/chat_session.py +379 -206
- fastworkflow/cli.py +80 -165
- fastworkflow/command_context_model.py +73 -7
- fastworkflow/command_executor.py +14 -5
- fastworkflow/command_metadata_api.py +106 -6
- fastworkflow/examples/fastworkflow.env +2 -1
- fastworkflow/examples/fastworkflow.passwords.env +2 -1
- fastworkflow/examples/retail_workflow/_commands/exchange_delivered_order_items.py +32 -3
- fastworkflow/examples/retail_workflow/_commands/find_user_id_by_email.py +6 -5
- fastworkflow/examples/retail_workflow/_commands/modify_pending_order_items.py +32 -3
- fastworkflow/examples/retail_workflow/_commands/return_delivered_order_items.py +13 -2
- fastworkflow/examples/retail_workflow/_commands/transfer_to_human_agents.py +1 -1
- fastworkflow/intent_clarification_agent.py +131 -0
- fastworkflow/mcp_server.py +3 -3
- fastworkflow/run/__main__.py +33 -40
- fastworkflow/run_fastapi_mcp/README.md +373 -0
- fastworkflow/run_fastapi_mcp/__main__.py +1300 -0
- fastworkflow/run_fastapi_mcp/conversation_store.py +391 -0
- fastworkflow/run_fastapi_mcp/jwt_manager.py +341 -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 +517 -0
- fastworkflow/train/__main__.py +1 -1
- fastworkflow/utils/chat_adapter.py +99 -0
- fastworkflow/utils/python_utils.py +4 -4
- fastworkflow/utils/react.py +258 -0
- fastworkflow/utils/signatures.py +338 -139
- fastworkflow/workflow.py +1 -5
- fastworkflow/workflow_agent.py +185 -133
- {fastworkflow-2.15.5.dist-info → fastworkflow-2.17.13.dist-info}/METADATA +16 -18
- {fastworkflow-2.15.5.dist-info → fastworkflow-2.17.13.dist-info}/RECORD +40 -30
- fastworkflow/run_agent/__main__.py +0 -294
- fastworkflow/run_agent/agent_module.py +0 -194
- /fastworkflow/{run_agent → run_fastapi_mcp}/__init__.py +0 -0
- {fastworkflow-2.15.5.dist-info → fastworkflow-2.17.13.dist-info}/LICENSE +0 -0
- {fastworkflow-2.15.5.dist-info → fastworkflow-2.17.13.dist-info}/WHEEL +0 -0
- {fastworkflow-2.15.5.dist-info → fastworkflow-2.17.13.dist-info}/entry_points.txt +0 -0
|
@@ -0,0 +1,1300 @@
|
|
|
1
|
+
"""
|
|
2
|
+
FastAPI application for FastWorkflow
|
|
3
|
+
Exposes FastWorkflow workflows as HTTP endpoints with synchronous and streaming execution
|
|
4
|
+
|
|
5
|
+
Implementation Status:
|
|
6
|
+
- ✅ All endpoints implemented per spec
|
|
7
|
+
- ✅ Session management and concurrency control
|
|
8
|
+
- ✅ Rdict-backed conversation persistence
|
|
9
|
+
- ✅ Agent trace collection and inclusion in responses
|
|
10
|
+
- ✅ SSE streaming for real-time trace events (/invoke_agent_stream)
|
|
11
|
+
- ✅ Error handling with proper HTTP status codes
|
|
12
|
+
- ✅ Conversation history extraction and restoration
|
|
13
|
+
- ✅ Session resume with conversation_id support
|
|
14
|
+
- ✅ Direct action execution (bypasses parameter extraction)
|
|
15
|
+
- ✅ Graceful shutdown (30s)
|
|
16
|
+
- ✅ Complete conversation dump (all users, active or not)
|
|
17
|
+
|
|
18
|
+
See docs/fastworkflow_fastapi_spec.md for complete specification.
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
import asyncio
|
|
22
|
+
import json
|
|
23
|
+
import os
|
|
24
|
+
import queue
|
|
25
|
+
import time
|
|
26
|
+
import traceback
|
|
27
|
+
from contextlib import asynccontextmanager
|
|
28
|
+
import argparse
|
|
29
|
+
|
|
30
|
+
import uvicorn
|
|
31
|
+
from jose import JWTError
|
|
32
|
+
from dotenv import dotenv_values
|
|
33
|
+
|
|
34
|
+
import fastworkflow
|
|
35
|
+
from fastworkflow.utils.logging import logger
|
|
36
|
+
|
|
37
|
+
from fastapi import FastAPI, HTTPException, status, Depends, Header
|
|
38
|
+
from fastapi.middleware.cors import CORSMiddleware
|
|
39
|
+
from fastapi.responses import HTMLResponse, JSONResponse, StreamingResponse
|
|
40
|
+
|
|
41
|
+
from .mcp_specific import setup_mcp
|
|
42
|
+
from .utils import (
|
|
43
|
+
get_channelconversations_dir,
|
|
44
|
+
ChannelSessionManager,
|
|
45
|
+
save_conversation_incremental,
|
|
46
|
+
InitializationRequest,
|
|
47
|
+
TokenResponse,
|
|
48
|
+
InitializeResponse,
|
|
49
|
+
SessionData,
|
|
50
|
+
InvokeRequest,
|
|
51
|
+
PerformActionRequest,
|
|
52
|
+
PostFeedbackRequest,
|
|
53
|
+
ActivateConversationRequest,
|
|
54
|
+
DumpConversationsRequest,
|
|
55
|
+
GenerateMCPTokenRequest,
|
|
56
|
+
wait_for_command_output,
|
|
57
|
+
collect_trace_events,
|
|
58
|
+
collect_trace_events_async,
|
|
59
|
+
get_session_from_jwt,
|
|
60
|
+
ensure_user_runtime_exists
|
|
61
|
+
)
|
|
62
|
+
from .jwt_manager import (
|
|
63
|
+
create_access_token,
|
|
64
|
+
create_refresh_token,
|
|
65
|
+
verify_token,
|
|
66
|
+
set_jwt_verification_mode,
|
|
67
|
+
JWT_ACCESS_TOKEN_EXPIRE_MINUTES
|
|
68
|
+
)
|
|
69
|
+
|
|
70
|
+
from .conversation_store import (
|
|
71
|
+
ConversationStore,
|
|
72
|
+
ConversationSummary,
|
|
73
|
+
generate_topic_and_summary,
|
|
74
|
+
extract_turns_from_history,
|
|
75
|
+
restore_history_from_turns
|
|
76
|
+
)
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
# ============================================================================
|
|
80
|
+
# Session Management
|
|
81
|
+
# ============================================================================
|
|
82
|
+
|
|
83
|
+
# Global session manager
|
|
84
|
+
session_manager = ChannelSessionManager()
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
# ============================================================================
|
|
88
|
+
# Dependencies
|
|
89
|
+
# ============================================================================
|
|
90
|
+
|
|
91
|
+
async def get_session_and_ensure_runtime(
|
|
92
|
+
session: SessionData = Depends(get_session_from_jwt)
|
|
93
|
+
) -> SessionData:
|
|
94
|
+
"""
|
|
95
|
+
FastAPI dependency that validates JWT token AND ensures user runtime exists.
|
|
96
|
+
|
|
97
|
+
This dependency combines JWT token validation with automatic session creation.
|
|
98
|
+
If the user's runtime doesn't exist in the session manager, it will be created
|
|
99
|
+
automatically using the same logic as the /initialize endpoint (but without
|
|
100
|
+
generating new JWT tokens).
|
|
101
|
+
|
|
102
|
+
This is particularly useful for MCP endpoints where the client already has
|
|
103
|
+
a valid JWT token but the server may have restarted or the session expired.
|
|
104
|
+
|
|
105
|
+
Args:
|
|
106
|
+
session: SessionData extracted and validated from JWT Bearer token
|
|
107
|
+
|
|
108
|
+
Returns:
|
|
109
|
+
SessionData: The same session data after ensuring runtime exists
|
|
110
|
+
|
|
111
|
+
Raises:
|
|
112
|
+
HTTPException: If token is invalid or session creation fails
|
|
113
|
+
|
|
114
|
+
Example:
|
|
115
|
+
Use as a dependency in FastAPI endpoints:
|
|
116
|
+
```python
|
|
117
|
+
@app.post("/endpoint")
|
|
118
|
+
async def endpoint(session: SessionData = Depends(get_session_and_ensure_runtime)):
|
|
119
|
+
# session.channel_id can now safely be used with session_manager
|
|
120
|
+
runtime = await session_manager.get_session(session.channel_id)
|
|
121
|
+
# runtime is guaranteed to exist
|
|
122
|
+
```
|
|
123
|
+
"""
|
|
124
|
+
# Ensure the user runtime exists (creates if missing)
|
|
125
|
+
await ensure_user_runtime_exists(
|
|
126
|
+
channel_id=session.channel_id,
|
|
127
|
+
session_manager=session_manager,
|
|
128
|
+
workflow_path=ARGS.workflow_path,
|
|
129
|
+
context=json.loads(ARGS.context) if ARGS.context else None,
|
|
130
|
+
startup_command=ARGS.startup_command,
|
|
131
|
+
startup_action=fastworkflow.Action(**json.loads(ARGS.startup_action)) if ARGS.startup_action else None,
|
|
132
|
+
http_bearer_token=session.http_bearer_token
|
|
133
|
+
)
|
|
134
|
+
|
|
135
|
+
return session
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
# ============================================================================
|
|
139
|
+
# FastAPI App Setup
|
|
140
|
+
# ============================================================================
|
|
141
|
+
|
|
142
|
+
@asynccontextmanager
|
|
143
|
+
async def lifespan(_app: FastAPI):
|
|
144
|
+
"""Startup and shutdown hooks"""
|
|
145
|
+
logger.info("FastWorkflow FastAPI service starting...")
|
|
146
|
+
logger.info(f"Startup with CLI params: workflow_path={ARGS.workflow_path}, env_file_path={ARGS.env_file_path}, passwords_file_path={ARGS.passwords_file_path}")
|
|
147
|
+
|
|
148
|
+
def initialize_fastworkflow_on_startup() -> None:
|
|
149
|
+
env_vars: dict[str, str] = {}
|
|
150
|
+
if ARGS.env_file_path:
|
|
151
|
+
env_vars |= dotenv_values(ARGS.env_file_path)
|
|
152
|
+
if ARGS.passwords_file_path:
|
|
153
|
+
env_vars.update(dotenv_values(ARGS.passwords_file_path))
|
|
154
|
+
fastworkflow.init(env_vars=env_vars)
|
|
155
|
+
|
|
156
|
+
# Configure JWT verification mode based on CLI parameter
|
|
157
|
+
set_jwt_verification_mode(ARGS.expect_encrypted_jwt)
|
|
158
|
+
|
|
159
|
+
async def _active_turn_channel_ids() -> list[str]:
|
|
160
|
+
active: list[str] = []
|
|
161
|
+
for channel_id in list(session_manager._sessions.keys()):
|
|
162
|
+
rt = await session_manager.get_session(channel_id)
|
|
163
|
+
if rt and rt.lock.locked():
|
|
164
|
+
active.append(channel_id)
|
|
165
|
+
return active
|
|
166
|
+
|
|
167
|
+
async def wait_for_active_turns_to_complete(max_wait_seconds: int) -> None:
|
|
168
|
+
logger.info(f"Waiting up to {max_wait_seconds}s for active turns to complete...")
|
|
169
|
+
start_time = time.time()
|
|
170
|
+
while time.time() - start_time < max_wait_seconds:
|
|
171
|
+
active_turns = await _active_turn_channel_ids()
|
|
172
|
+
if not active_turns:
|
|
173
|
+
logger.info("All turns completed, shutting down gracefully")
|
|
174
|
+
return
|
|
175
|
+
logger.debug(f"Waiting for {len(active_turns)} active turns: {active_turns}")
|
|
176
|
+
await asyncio.sleep(0.5)
|
|
177
|
+
remaining = await _active_turn_channel_ids()
|
|
178
|
+
logger.warning(f"Shutdown timeout reached with {len(remaining)} turns still active")
|
|
179
|
+
|
|
180
|
+
async def finalize_conversations_on_shutdown() -> None:
|
|
181
|
+
logger.info("Finalizing conversations with topic and summary...")
|
|
182
|
+
for channel_id in list(session_manager._sessions.keys()):
|
|
183
|
+
runtime = await session_manager.get_session(channel_id)
|
|
184
|
+
if not runtime:
|
|
185
|
+
continue
|
|
186
|
+
if turns := extract_turns_from_history(runtime.chat_session.conversation_history):
|
|
187
|
+
try:
|
|
188
|
+
topic, summary = generate_topic_and_summary(turns)
|
|
189
|
+
if runtime.active_conversation_id > 0:
|
|
190
|
+
runtime.conversation_store.update_conversation_topic_summary(
|
|
191
|
+
runtime.active_conversation_id, topic, summary
|
|
192
|
+
)
|
|
193
|
+
logger.info(f"Finalized conversation {runtime.active_conversation_id} for user {channel_id} during shutdown")
|
|
194
|
+
else:
|
|
195
|
+
logger.warning(f"Conversation history exists but no active_conversation_id for user {channel_id} during shutdown")
|
|
196
|
+
conv_id = runtime.conversation_store.save_conversation(topic, summary, turns)
|
|
197
|
+
logger.info(f"Created conversation {conv_id} for user {channel_id} during shutdown")
|
|
198
|
+
except Exception as e:
|
|
199
|
+
logger.error(f"Failed to finalize conversation for user {channel_id} during shutdown: {e}")
|
|
200
|
+
|
|
201
|
+
async def stop_all_chat_sessions() -> None:
|
|
202
|
+
for channel_id in list(session_manager._sessions.keys()):
|
|
203
|
+
runtime = await session_manager.get_session(channel_id)
|
|
204
|
+
if runtime:
|
|
205
|
+
runtime.chat_session.stop_workflow()
|
|
206
|
+
|
|
207
|
+
try:
|
|
208
|
+
initialize_fastworkflow_on_startup()
|
|
209
|
+
yield
|
|
210
|
+
finally:
|
|
211
|
+
logger.info("FastWorkflow FastAPI service shutting down...")
|
|
212
|
+
await wait_for_active_turns_to_complete(max_wait_seconds=30)
|
|
213
|
+
await finalize_conversations_on_shutdown()
|
|
214
|
+
await stop_all_chat_sessions()
|
|
215
|
+
logger.info("FastWorkflow FastAPI service shutdown complete")
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
def load_args():
|
|
219
|
+
parser = argparse.ArgumentParser()
|
|
220
|
+
parser.add_argument("--workflow_path", required=True)
|
|
221
|
+
parser.add_argument("--env_file_path", required=False)
|
|
222
|
+
parser.add_argument("--passwords_file_path", required=False)
|
|
223
|
+
parser.add_argument("--context", required=False) # JSON string
|
|
224
|
+
parser.add_argument("--startup_command", required=False)
|
|
225
|
+
parser.add_argument("--startup_action", required=False) # JSON string
|
|
226
|
+
parser.add_argument("--project_folderpath", required=False)
|
|
227
|
+
parser.add_argument("--port", type=int, default=8000, help="Port to run the server on (default: 8000)")
|
|
228
|
+
parser.add_argument("--host", default="0.0.0.0", help="Host to bind the server to (default: 0.0.0.0)")
|
|
229
|
+
parser.add_argument("--expect_encrypted_jwt", action="store_true", default=False,
|
|
230
|
+
help="Enable JWT signature verification (default: unsigned tokens accepted for trusted networks)")
|
|
231
|
+
return parser.parse_args()
|
|
232
|
+
|
|
233
|
+
ARGS = load_args()
|
|
234
|
+
|
|
235
|
+
app = FastAPI(
|
|
236
|
+
title="FastWorkflow API",
|
|
237
|
+
description="HTTP interface for FastWorkflow workflows with JWT authentication",
|
|
238
|
+
version="1.0.0",
|
|
239
|
+
lifespan=lifespan,
|
|
240
|
+
swagger_ui_parameters={
|
|
241
|
+
"persistAuthorization": True # Remember Bearer token in Swagger UI
|
|
242
|
+
}
|
|
243
|
+
)
|
|
244
|
+
|
|
245
|
+
# Configure OpenAPI security scheme for JWT Bearer tokens
|
|
246
|
+
# This enables the "Authorize" button in Swagger UI
|
|
247
|
+
# Note: The security scheme is automatically generated by HTTPBearer in utils.py,
|
|
248
|
+
# but we customize it here to improve the description and ensure proper integration
|
|
249
|
+
from fastapi.openapi.utils import get_openapi
|
|
250
|
+
|
|
251
|
+
def custom_openapi():
|
|
252
|
+
if app.openapi_schema:
|
|
253
|
+
return app.openapi_schema
|
|
254
|
+
openapi_schema = get_openapi(
|
|
255
|
+
title=app.title,
|
|
256
|
+
version=app.version,
|
|
257
|
+
description=app.description,
|
|
258
|
+
routes=app.routes,
|
|
259
|
+
)
|
|
260
|
+
|
|
261
|
+
# Enhance the auto-generated Bearer token security scheme with better documentation
|
|
262
|
+
# The HTTPBearer dependency in utils.py creates the base scheme, we just improve it
|
|
263
|
+
if "components" in openapi_schema and "securitySchemes" in openapi_schema["components"] and "BearerAuth" in openapi_schema["components"]["securitySchemes"]:
|
|
264
|
+
openapi_schema["components"]["securitySchemes"]["BearerAuth"]["description"] = (
|
|
265
|
+
"JWT access token from /initialize or /refresh_token endpoint. "
|
|
266
|
+
"Enter ONLY the token (Swagger UI automatically adds 'Bearer ' prefix)"
|
|
267
|
+
)
|
|
268
|
+
|
|
269
|
+
# Apply security globally to all endpoints except public ones
|
|
270
|
+
for path, path_item in openapi_schema["paths"].items():
|
|
271
|
+
# Skip endpoints that don't require authentication
|
|
272
|
+
if path in ["/initialize", "/refresh_token", "/", "/admin/dump_all_conversations", "/admin/generate_mcp_token"]:
|
|
273
|
+
continue
|
|
274
|
+
for method in path_item:
|
|
275
|
+
if method in ["get", "post", "put", "delete", "patch"] and "security" not in path_item[method]:
|
|
276
|
+
path_item[method]["security"] = [{"BearerAuth": []}]
|
|
277
|
+
|
|
278
|
+
app.openapi_schema = openapi_schema
|
|
279
|
+
return app.openapi_schema
|
|
280
|
+
|
|
281
|
+
app.openapi = custom_openapi
|
|
282
|
+
|
|
283
|
+
# CORS middleware
|
|
284
|
+
app.add_middleware(
|
|
285
|
+
CORSMiddleware,
|
|
286
|
+
allow_origins=["*"], # Configure appropriately for production
|
|
287
|
+
allow_credentials=True,
|
|
288
|
+
allow_methods=["*"],
|
|
289
|
+
allow_headers=["*"],
|
|
290
|
+
)
|
|
291
|
+
|
|
292
|
+
# ============================================================================
|
|
293
|
+
# Endpoints
|
|
294
|
+
# ============================================================================
|
|
295
|
+
|
|
296
|
+
@app.get("/", response_class=HTMLResponse, operation_id="root")
|
|
297
|
+
async def root():
|
|
298
|
+
"""Root endpoint with health check and docs link"""
|
|
299
|
+
return """
|
|
300
|
+
<html>
|
|
301
|
+
<head>
|
|
302
|
+
<title>FastWorkflow API</title>
|
|
303
|
+
</head>
|
|
304
|
+
<body>
|
|
305
|
+
<h1>FastWorkflow API is running!</h1>
|
|
306
|
+
<p>For API testing, go to <a href="/docs">Swagger UI</a></p>
|
|
307
|
+
<p>For API documentation, go to <a href="/redoc">ReDoc</a></p>
|
|
308
|
+
</body>
|
|
309
|
+
</html>
|
|
310
|
+
"""
|
|
311
|
+
|
|
312
|
+
|
|
313
|
+
@app.post(
|
|
314
|
+
"/initialize",
|
|
315
|
+
operation_id="rest_initialize",
|
|
316
|
+
response_model=InitializeResponse,
|
|
317
|
+
status_code=status.HTTP_200_OK,
|
|
318
|
+
responses={
|
|
319
|
+
200: {"description": "Session successfully initialized, JWT tokens returned with optional startup output"},
|
|
320
|
+
400: {"description": "Both startup_command and startup_action provided, or user_id missing when startup provided"},
|
|
321
|
+
422: {"description": "Invalid paths or missing env vars"},
|
|
322
|
+
500: {"description": "Internal error during initialization"}
|
|
323
|
+
}
|
|
324
|
+
)
|
|
325
|
+
async def initialize(request: InitializationRequest) -> InitializeResponse:
|
|
326
|
+
"""
|
|
327
|
+
Initialize a FastWorkflow session for a channel.
|
|
328
|
+
Creates or resumes a ChatSession and starts the workflow.
|
|
329
|
+
Optionally executes a startup command/action and returns its output.
|
|
330
|
+
"""
|
|
331
|
+
try:
|
|
332
|
+
channel_id = request.channel_id
|
|
333
|
+
user_id = request.user_id
|
|
334
|
+
logger.info(f"Initializing session for channel_id: {channel_id}, user_id: {user_id}")
|
|
335
|
+
|
|
336
|
+
# Validate XOR: can't have both startup_command and startup_action
|
|
337
|
+
if request.startup_command and request.startup_action:
|
|
338
|
+
raise HTTPException(
|
|
339
|
+
status_code=status.HTTP_400_BAD_REQUEST,
|
|
340
|
+
detail="Cannot provide both startup_command and startup_action. Choose one or neither."
|
|
341
|
+
)
|
|
342
|
+
|
|
343
|
+
# Validate: if startup provided, user_id is required
|
|
344
|
+
if (request.startup_command or request.startup_action) and not user_id:
|
|
345
|
+
raise HTTPException(
|
|
346
|
+
status_code=status.HTTP_400_BAD_REQUEST,
|
|
347
|
+
detail="user_id is required when startup_command or startup_action is provided"
|
|
348
|
+
)
|
|
349
|
+
|
|
350
|
+
# Check if user already has an active session
|
|
351
|
+
existing_runtime = await session_manager.get_session(channel_id)
|
|
352
|
+
if existing_runtime:
|
|
353
|
+
logger.info(f"Session for channel_id {channel_id} already exists, generating new tokens")
|
|
354
|
+
|
|
355
|
+
# Generate new JWT tokens for existing session
|
|
356
|
+
access_token = create_access_token(channel_id, user_id)
|
|
357
|
+
refresh_token = create_refresh_token(channel_id, user_id)
|
|
358
|
+
|
|
359
|
+
return InitializeResponse(
|
|
360
|
+
access_token=access_token,
|
|
361
|
+
refresh_token=refresh_token,
|
|
362
|
+
token_type="bearer",
|
|
363
|
+
expires_in=JWT_ACCESS_TOKEN_EXPIRE_MINUTES * 60 # Convert to seconds
|
|
364
|
+
)
|
|
365
|
+
|
|
366
|
+
# Prepare startup action if provided in request (takes precedence over CLI args)
|
|
367
|
+
startup_action = None
|
|
368
|
+
startup_command_str = request.startup_command or ARGS.startup_command
|
|
369
|
+
|
|
370
|
+
if request.startup_action:
|
|
371
|
+
startup_action = fastworkflow.Action(**request.startup_action)
|
|
372
|
+
elif ARGS.startup_action:
|
|
373
|
+
startup_action = fastworkflow.Action(**json.loads(ARGS.startup_action))
|
|
374
|
+
|
|
375
|
+
# Use the modular helper function to create the session
|
|
376
|
+
await ensure_user_runtime_exists(
|
|
377
|
+
channel_id=channel_id,
|
|
378
|
+
session_manager=session_manager,
|
|
379
|
+
workflow_path=ARGS.workflow_path,
|
|
380
|
+
context=json.loads(ARGS.context) if ARGS.context else None,
|
|
381
|
+
startup_command=None, # Don't execute during session creation
|
|
382
|
+
startup_action=None, # Don't execute during session creation
|
|
383
|
+
stream_format=(request.stream_format if request.stream_format in ("ndjson", "sse") else "ndjson")
|
|
384
|
+
)
|
|
385
|
+
|
|
386
|
+
# Execute startup if provided
|
|
387
|
+
startup_output = None
|
|
388
|
+
if startup_command_str or startup_action:
|
|
389
|
+
runtime = await session_manager.get_session(channel_id)
|
|
390
|
+
if not runtime:
|
|
391
|
+
raise HTTPException(
|
|
392
|
+
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
|
393
|
+
detail=f"Runtime not found after creation for channel_id: {channel_id}"
|
|
394
|
+
)
|
|
395
|
+
|
|
396
|
+
chat_session = runtime.chat_session
|
|
397
|
+
|
|
398
|
+
# Execute startup action or command
|
|
399
|
+
if startup_action:
|
|
400
|
+
# Execute action directly (like perform_action)
|
|
401
|
+
logger.info(f"Executing startup action for channel_id {channel_id}: {startup_action.command_name}")
|
|
402
|
+
chat_session.user_message_queue.put(startup_action)
|
|
403
|
+
else:
|
|
404
|
+
# Execute command via assistant path (deterministic) - needs / prefix
|
|
405
|
+
assistant_command = f"/{startup_command_str.lstrip('/')}"
|
|
406
|
+
logger.info(f"Executing startup command for channel_id {channel_id}: {assistant_command}")
|
|
407
|
+
chat_session.user_message_queue.put(assistant_command)
|
|
408
|
+
|
|
409
|
+
# Wait for output
|
|
410
|
+
try:
|
|
411
|
+
startup_output = await wait_for_command_output(
|
|
412
|
+
runtime=runtime,
|
|
413
|
+
timeout_seconds=60
|
|
414
|
+
)
|
|
415
|
+
|
|
416
|
+
# Collect traces
|
|
417
|
+
traces = await collect_trace_events_async(
|
|
418
|
+
trace_queue=chat_session.command_trace_queue,
|
|
419
|
+
user_id=user_id
|
|
420
|
+
)
|
|
421
|
+
|
|
422
|
+
# Persist the startup turn to conversation store
|
|
423
|
+
if startup_output:
|
|
424
|
+
# Save turn incrementally using existing conversation store in runtime
|
|
425
|
+
save_conversation_incremental(runtime, extract_turns_from_history, logger)
|
|
426
|
+
|
|
427
|
+
logger.info(f"Startup execution completed and persisted for channel_id: {channel_id}")
|
|
428
|
+
|
|
429
|
+
except asyncio.TimeoutError:
|
|
430
|
+
logger.error(f"Startup execution timed out for channel_id: {channel_id}")
|
|
431
|
+
raise HTTPException(
|
|
432
|
+
status_code=status.HTTP_504_GATEWAY_TIMEOUT,
|
|
433
|
+
detail=f"Startup execution timed out for channel_id: {channel_id}"
|
|
434
|
+
)
|
|
435
|
+
|
|
436
|
+
# Generate JWT tokens
|
|
437
|
+
access_token = create_access_token(channel_id, user_id)
|
|
438
|
+
refresh_token = create_refresh_token(channel_id, user_id)
|
|
439
|
+
|
|
440
|
+
return InitializeResponse(
|
|
441
|
+
access_token=access_token,
|
|
442
|
+
refresh_token=refresh_token,
|
|
443
|
+
token_type="bearer",
|
|
444
|
+
expires_in=JWT_ACCESS_TOKEN_EXPIRE_MINUTES * 60, # Convert to seconds
|
|
445
|
+
startup_output=startup_output
|
|
446
|
+
)
|
|
447
|
+
|
|
448
|
+
except HTTPException:
|
|
449
|
+
raise
|
|
450
|
+
except Exception as e:
|
|
451
|
+
logger.error(f"Error initializing session for channel_id: {request.channel_id}: {e}")
|
|
452
|
+
traceback.print_exc()
|
|
453
|
+
raise HTTPException(
|
|
454
|
+
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
|
455
|
+
detail=f"Internal error in initialize() for channel_id: {request.channel_id}",
|
|
456
|
+
) from e
|
|
457
|
+
|
|
458
|
+
|
|
459
|
+
@app.post(
|
|
460
|
+
"/refresh_token",
|
|
461
|
+
operation_id="refresh_token",
|
|
462
|
+
response_model=TokenResponse,
|
|
463
|
+
status_code=status.HTTP_200_OK,
|
|
464
|
+
responses={
|
|
465
|
+
200: {"description": "New access token issued successfully"},
|
|
466
|
+
401: {"description": "Invalid or expired refresh token"},
|
|
467
|
+
404: {"description": "Session not found (session may have been cleaned up)"}
|
|
468
|
+
}
|
|
469
|
+
)
|
|
470
|
+
async def refresh_token(
|
|
471
|
+
authorization: str = Header(..., description="Refresh token in Bearer format")
|
|
472
|
+
) -> TokenResponse:
|
|
473
|
+
"""
|
|
474
|
+
Refresh an access token using a valid refresh token.
|
|
475
|
+
Returns a new access token and a new refresh token.
|
|
476
|
+
|
|
477
|
+
Requires the refresh token to be passed in the Authorization header (Bearer token format).
|
|
478
|
+
"""
|
|
479
|
+
try:
|
|
480
|
+
# Validate Bearer token format
|
|
481
|
+
if not authorization.startswith("Bearer "):
|
|
482
|
+
raise HTTPException(
|
|
483
|
+
status_code=status.HTTP_401_UNAUTHORIZED,
|
|
484
|
+
detail="Invalid Authorization header format. Expected: Bearer <refresh_token>",
|
|
485
|
+
headers={"WWW-Authenticate": "Bearer"}
|
|
486
|
+
)
|
|
487
|
+
|
|
488
|
+
# Extract token
|
|
489
|
+
refresh_token_str = authorization[7:] # Remove "Bearer " prefix
|
|
490
|
+
|
|
491
|
+
# Verify refresh token
|
|
492
|
+
try:
|
|
493
|
+
payload = verify_token(refresh_token_str, expected_type="refresh")
|
|
494
|
+
except JWTError as e:
|
|
495
|
+
raise HTTPException(
|
|
496
|
+
status_code=status.HTTP_401_UNAUTHORIZED,
|
|
497
|
+
detail=f"Invalid or expired refresh token: {str(e)}",
|
|
498
|
+
headers={"WWW-Authenticate": "Bearer"},
|
|
499
|
+
) from e
|
|
500
|
+
|
|
501
|
+
# Extract channel_id and optional user_id from payload
|
|
502
|
+
channel_id = payload["sub"]
|
|
503
|
+
user_id = payload.get("uid")
|
|
504
|
+
|
|
505
|
+
# Verify session still exists
|
|
506
|
+
runtime = await session_manager.get_session(channel_id)
|
|
507
|
+
if not runtime:
|
|
508
|
+
raise HTTPException(
|
|
509
|
+
status_code=status.HTTP_404_NOT_FOUND,
|
|
510
|
+
detail=f"User session not found: {channel_id} (may have been cleaned up)"
|
|
511
|
+
)
|
|
512
|
+
|
|
513
|
+
# Generate new tokens with same user_id
|
|
514
|
+
new_access_token = create_access_token(channel_id, user_id)
|
|
515
|
+
new_refresh_token = create_refresh_token(channel_id, user_id)
|
|
516
|
+
|
|
517
|
+
logger.info(f"Refreshed tokens for channel_id: {channel_id}, user_id: {user_id}")
|
|
518
|
+
|
|
519
|
+
return TokenResponse(
|
|
520
|
+
access_token=new_access_token,
|
|
521
|
+
refresh_token=new_refresh_token,
|
|
522
|
+
token_type="bearer",
|
|
523
|
+
expires_in=JWT_ACCESS_TOKEN_EXPIRE_MINUTES * 60 # Convert to seconds
|
|
524
|
+
)
|
|
525
|
+
|
|
526
|
+
except HTTPException:
|
|
527
|
+
raise
|
|
528
|
+
except Exception as e:
|
|
529
|
+
logger.error(f"Error refreshing token: {e}")
|
|
530
|
+
traceback.print_exc()
|
|
531
|
+
raise HTTPException(
|
|
532
|
+
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
|
533
|
+
detail="Internal error in refresh_token()",
|
|
534
|
+
) from e
|
|
535
|
+
|
|
536
|
+
|
|
537
|
+
@app.post(
|
|
538
|
+
"/invoke_agent",
|
|
539
|
+
operation_id="rest_invoke_agent",
|
|
540
|
+
response_model=None, # Use custom response to include traces
|
|
541
|
+
status_code=status.HTTP_200_OK,
|
|
542
|
+
responses={
|
|
543
|
+
200: {"description": "Agent query processed successfully"},
|
|
544
|
+
401: {"description": "Invalid or expired JWT token"},
|
|
545
|
+
404: {"description": "Session not found"},
|
|
546
|
+
409: {"description": "Concurrent turn already in progress"},
|
|
547
|
+
504: {"description": "Command execution timed out"}
|
|
548
|
+
}
|
|
549
|
+
)
|
|
550
|
+
async def invoke_agent(
|
|
551
|
+
request: InvokeRequest,
|
|
552
|
+
session: SessionData = Depends(get_session_and_ensure_runtime)
|
|
553
|
+
) -> JSONResponse:
|
|
554
|
+
"""
|
|
555
|
+
Submit a natural language query to the agent.
|
|
556
|
+
Leading '/' characters are stripped for compatibility.
|
|
557
|
+
|
|
558
|
+
Requires a valid JWT access token in the Authorization header (Bearer token format).
|
|
559
|
+
"""
|
|
560
|
+
channel_id = session.channel_id
|
|
561
|
+
user_id = session.user_id
|
|
562
|
+
try:
|
|
563
|
+
runtime = await session_manager.get_session(channel_id)
|
|
564
|
+
if not runtime:
|
|
565
|
+
raise HTTPException(
|
|
566
|
+
status_code=status.HTTP_404_NOT_FOUND,
|
|
567
|
+
detail=f"User session not found: {channel_id}"
|
|
568
|
+
)
|
|
569
|
+
|
|
570
|
+
# Serialize turns per user
|
|
571
|
+
if runtime.lock.locked():
|
|
572
|
+
raise HTTPException(
|
|
573
|
+
status_code=status.HTTP_409_CONFLICT,
|
|
574
|
+
detail=f"A turn is already in progress for user: {channel_id}"
|
|
575
|
+
)
|
|
576
|
+
|
|
577
|
+
async with runtime.lock:
|
|
578
|
+
# Strip leading slashes from user query
|
|
579
|
+
user_query = request.user_query.lstrip('/')
|
|
580
|
+
|
|
581
|
+
# Enqueue the user message
|
|
582
|
+
runtime.chat_session.user_message_queue.put(user_query)
|
|
583
|
+
|
|
584
|
+
# Wait for command output
|
|
585
|
+
command_output = await wait_for_command_output(runtime, request.timeout_seconds)
|
|
586
|
+
|
|
587
|
+
# Incrementally save conversation turns (without generating topic/summary)
|
|
588
|
+
save_conversation_incremental(runtime, extract_turns_from_history, logger)
|
|
589
|
+
|
|
590
|
+
traces = collect_trace_events(runtime, user_id=user_id)
|
|
591
|
+
# Build response with traces
|
|
592
|
+
response_data = command_output.model_dump()
|
|
593
|
+
if traces:
|
|
594
|
+
response_data["traces"] = traces
|
|
595
|
+
|
|
596
|
+
return JSONResponse(content=response_data)
|
|
597
|
+
|
|
598
|
+
except HTTPException:
|
|
599
|
+
raise
|
|
600
|
+
except Exception as e:
|
|
601
|
+
logger.error(f"Error in invoke_agent for user {channel_id}: {e}")
|
|
602
|
+
traceback.print_exc()
|
|
603
|
+
raise HTTPException(
|
|
604
|
+
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
|
605
|
+
detail=f"Internal error in invoke_agent() for channel_id: {channel_id}",
|
|
606
|
+
) from e
|
|
607
|
+
|
|
608
|
+
|
|
609
|
+
@app.post(
|
|
610
|
+
"/invoke_agent_stream",
|
|
611
|
+
operation_id="invoke_agent",
|
|
612
|
+
responses={
|
|
613
|
+
200: {
|
|
614
|
+
"description": "Stream with trace events and final command output",
|
|
615
|
+
"content": {
|
|
616
|
+
"application/x-ndjson": {},
|
|
617
|
+
"text/event-stream": {}
|
|
618
|
+
}
|
|
619
|
+
},
|
|
620
|
+
401: {"description": "Invalid or expired JWT token"},
|
|
621
|
+
404: {"description": "Session not found"},
|
|
622
|
+
409: {"description": "Concurrent turn already in progress"},
|
|
623
|
+
504: {"description": "Command execution timed out"}
|
|
624
|
+
}
|
|
625
|
+
)
|
|
626
|
+
async def invoke_agent_stream(
|
|
627
|
+
request: InvokeRequest,
|
|
628
|
+
session: SessionData = Depends(get_session_and_ensure_runtime)
|
|
629
|
+
):
|
|
630
|
+
"""
|
|
631
|
+
Submit a natural language query to the agent and stream responses.
|
|
632
|
+
|
|
633
|
+
Streams via NDJSON or SSE based on the session's stream_format preference.
|
|
634
|
+
- NDJSON: {"type":"trace","data":<trace_json>} for each trace, {"type":"output","data":<CommandOutput_json>} for final result
|
|
635
|
+
- SSE: event: trace/output with data payloads
|
|
636
|
+
|
|
637
|
+
Requires a valid JWT access token in the Authorization header (Bearer token format).
|
|
638
|
+
Exposed as 'invoke_agent' tool for MCP clients (who don't need JWT auth).
|
|
639
|
+
"""
|
|
640
|
+
channel_id = session.channel_id
|
|
641
|
+
user_id = session.user_id
|
|
642
|
+
|
|
643
|
+
# Get runtime and validate session exists
|
|
644
|
+
runtime = await session_manager.get_session(channel_id)
|
|
645
|
+
if not runtime:
|
|
646
|
+
return JSONResponse(
|
|
647
|
+
status_code=status.HTTP_404_NOT_FOUND,
|
|
648
|
+
content={"detail": f"User session not found: {channel_id}"}
|
|
649
|
+
)
|
|
650
|
+
|
|
651
|
+
async def ndjson_stream():
|
|
652
|
+
try:
|
|
653
|
+
if runtime.lock.locked():
|
|
654
|
+
yield {"type": "error", "data": {"detail": f"A turn is already in progress for user: {channel_id}"}}
|
|
655
|
+
return
|
|
656
|
+
|
|
657
|
+
async with runtime.lock:
|
|
658
|
+
runtime.chat_session.user_message_queue.put(request.user_query.lstrip("/"))
|
|
659
|
+
start_time = time.time()
|
|
660
|
+
command_output = None
|
|
661
|
+
|
|
662
|
+
while time.time() - start_time < request.timeout_seconds:
|
|
663
|
+
while True:
|
|
664
|
+
try:
|
|
665
|
+
evt = runtime.chat_session.command_trace_queue.get_nowait()
|
|
666
|
+
trace_json = {
|
|
667
|
+
"direction": evt.direction.value if hasattr(evt.direction, "value") else str(evt.direction),
|
|
668
|
+
"raw_command": evt.raw_command,
|
|
669
|
+
"command_name": evt.command_name,
|
|
670
|
+
"parameters": evt.parameters,
|
|
671
|
+
"response_text": evt.response_text,
|
|
672
|
+
"success": evt.success,
|
|
673
|
+
"timestamp_ms": evt.timestamp_ms,
|
|
674
|
+
}
|
|
675
|
+
if user_id is not None:
|
|
676
|
+
trace_json["user_id"] = user_id
|
|
677
|
+
yield {"type": "trace", "data": trace_json}
|
|
678
|
+
except queue.Empty:
|
|
679
|
+
break
|
|
680
|
+
|
|
681
|
+
try:
|
|
682
|
+
command_output = runtime.chat_session.command_output_queue.get_nowait()
|
|
683
|
+
break
|
|
684
|
+
except queue.Empty:
|
|
685
|
+
await asyncio.sleep(0.1)
|
|
686
|
+
continue
|
|
687
|
+
|
|
688
|
+
# Drain remaining traces
|
|
689
|
+
while True:
|
|
690
|
+
try:
|
|
691
|
+
evt = runtime.chat_session.command_trace_queue.get_nowait()
|
|
692
|
+
trace_json = {
|
|
693
|
+
"direction": evt.direction.value if hasattr(evt.direction, "value") else str(evt.direction),
|
|
694
|
+
"raw_command": evt.raw_command,
|
|
695
|
+
"command_name": evt.command_name,
|
|
696
|
+
"parameters": evt.parameters,
|
|
697
|
+
"response_text": evt.response_text,
|
|
698
|
+
"success": evt.success,
|
|
699
|
+
"timestamp_ms": evt.timestamp_ms,
|
|
700
|
+
}
|
|
701
|
+
if user_id is not None:
|
|
702
|
+
trace_json["user_id"] = user_id
|
|
703
|
+
yield {"type": "trace", "data": trace_json}
|
|
704
|
+
except queue.Empty:
|
|
705
|
+
break
|
|
706
|
+
|
|
707
|
+
if command_output is None:
|
|
708
|
+
yield {"type": "error", "data": {"detail": f"Command execution timed out after {request.timeout_seconds} seconds"}}
|
|
709
|
+
return
|
|
710
|
+
|
|
711
|
+
save_conversation_incremental(runtime, extract_turns_from_history, logger)
|
|
712
|
+
yield {"type": "output", "data": command_output.model_dump()}
|
|
713
|
+
|
|
714
|
+
except Exception as e:
|
|
715
|
+
logger.error(f"Error in invoke_agent_stream for user {channel_id}: {e}")
|
|
716
|
+
traceback.print_exc()
|
|
717
|
+
yield {"type": "error", "data": {"detail": f"Internal error in invoke_agent_stream() for channel_id: {channel_id}"}}
|
|
718
|
+
|
|
719
|
+
async def sse_stream():
|
|
720
|
+
try:
|
|
721
|
+
if runtime.lock.locked():
|
|
722
|
+
yield "event: error\n" + f"data: {json.dumps({'detail': f'A turn is already in progress for user: {channel_id}'})}\n\n"
|
|
723
|
+
return
|
|
724
|
+
|
|
725
|
+
async with runtime.lock:
|
|
726
|
+
runtime.chat_session.user_message_queue.put(request.user_query.lstrip("/"))
|
|
727
|
+
|
|
728
|
+
def fmt(evt):
|
|
729
|
+
trace_data = {
|
|
730
|
+
"direction": evt.direction.value if hasattr(evt.direction, "value") else str(evt.direction),
|
|
731
|
+
"raw_command": evt.raw_command,
|
|
732
|
+
"command_name": evt.command_name,
|
|
733
|
+
"parameters": evt.parameters,
|
|
734
|
+
"response_text": evt.response_text,
|
|
735
|
+
"success": evt.success,
|
|
736
|
+
"timestamp_ms": evt.timestamp_ms,
|
|
737
|
+
}
|
|
738
|
+
if user_id is not None:
|
|
739
|
+
trace_data["user_id"] = user_id
|
|
740
|
+
return f"event: trace\ndata: {json.dumps(trace_data)}\n\n"
|
|
741
|
+
|
|
742
|
+
start_time = time.time()
|
|
743
|
+
command_output = None
|
|
744
|
+
|
|
745
|
+
while time.time() - start_time < request.timeout_seconds:
|
|
746
|
+
while True:
|
|
747
|
+
try:
|
|
748
|
+
evt = runtime.chat_session.command_trace_queue.get_nowait()
|
|
749
|
+
yield fmt(evt)
|
|
750
|
+
except queue.Empty:
|
|
751
|
+
break
|
|
752
|
+
|
|
753
|
+
try:
|
|
754
|
+
command_output = runtime.chat_session.command_output_queue.get_nowait()
|
|
755
|
+
break
|
|
756
|
+
except queue.Empty:
|
|
757
|
+
await asyncio.sleep(0.1)
|
|
758
|
+
continue
|
|
759
|
+
|
|
760
|
+
# Drain remaining traces
|
|
761
|
+
while True:
|
|
762
|
+
try:
|
|
763
|
+
evt = runtime.chat_session.command_trace_queue.get_nowait()
|
|
764
|
+
yield fmt(evt)
|
|
765
|
+
except queue.Empty:
|
|
766
|
+
break
|
|
767
|
+
|
|
768
|
+
if command_output is None:
|
|
769
|
+
yield "event: error\n" + f"data: {json.dumps({'detail': f'Command execution timed out after {request.timeout_seconds} seconds'})}\n\n"
|
|
770
|
+
return
|
|
771
|
+
|
|
772
|
+
save_conversation_incremental(runtime, extract_turns_from_history, logger)
|
|
773
|
+
yield "event: output\n" + f"data: {json.dumps(command_output.model_dump())}\n\n"
|
|
774
|
+
|
|
775
|
+
except Exception as e:
|
|
776
|
+
logger.error(f"Error in invoke_agent_stream SSE for user {channel_id}: {e}")
|
|
777
|
+
traceback.print_exc()
|
|
778
|
+
yield "event: error\n" + f"data: {json.dumps({'detail': f'Internal error in invoke_agent_stream() for channel_id: {channel_id}'})}\n\n"
|
|
779
|
+
|
|
780
|
+
# Route to appropriate stream format
|
|
781
|
+
if runtime.stream_format == "sse":
|
|
782
|
+
return StreamingResponse(
|
|
783
|
+
sse_stream(),
|
|
784
|
+
media_type="text/event-stream",
|
|
785
|
+
headers={"Cache-Control": "no-cache", "X-Accel-Buffering": "no"},
|
|
786
|
+
)
|
|
787
|
+
|
|
788
|
+
# Default to NDJSON with JSON serialization wrapper
|
|
789
|
+
async def ndjson_body():
|
|
790
|
+
async for part in ndjson_stream():
|
|
791
|
+
yield json.dumps(part) + "\n"
|
|
792
|
+
|
|
793
|
+
return StreamingResponse(ndjson_body(), media_type="application/x-ndjson")
|
|
794
|
+
|
|
795
|
+
|
|
796
|
+
@app.post(
|
|
797
|
+
"/invoke_assistant",
|
|
798
|
+
operation_id="invoke_assistant",
|
|
799
|
+
response_model=None,
|
|
800
|
+
status_code=status.HTTP_200_OK,
|
|
801
|
+
responses={
|
|
802
|
+
200: {"description": "Assistant query processed successfully"},
|
|
803
|
+
401: {"description": "Invalid or expired JWT token"},
|
|
804
|
+
404: {"description": "Session not found"},
|
|
805
|
+
409: {"description": "Concurrent turn already in progress"},
|
|
806
|
+
504: {"description": "Command execution timed out"}
|
|
807
|
+
}
|
|
808
|
+
)
|
|
809
|
+
async def invoke_assistant(
|
|
810
|
+
request: InvokeRequest,
|
|
811
|
+
session: SessionData = Depends(get_session_and_ensure_runtime)
|
|
812
|
+
) -> JSONResponse:
|
|
813
|
+
"""
|
|
814
|
+
Submit a query for deterministic/assistant execution (no planning).
|
|
815
|
+
The query is processed as-is without agent mode.
|
|
816
|
+
|
|
817
|
+
Requires a valid JWT access token in the Authorization header (Bearer token format).
|
|
818
|
+
"""
|
|
819
|
+
channel_id = session.channel_id
|
|
820
|
+
user_id = session.user_id
|
|
821
|
+
try:
|
|
822
|
+
runtime = await session_manager.get_session(channel_id)
|
|
823
|
+
if not runtime:
|
|
824
|
+
raise HTTPException(
|
|
825
|
+
status_code=status.HTTP_404_NOT_FOUND,
|
|
826
|
+
detail=f"User session not found: {channel_id}"
|
|
827
|
+
)
|
|
828
|
+
|
|
829
|
+
if runtime.lock.locked():
|
|
830
|
+
raise HTTPException(
|
|
831
|
+
status_code=status.HTTP_409_CONFLICT,
|
|
832
|
+
detail=f"A turn is already in progress for user: {channel_id}"
|
|
833
|
+
)
|
|
834
|
+
|
|
835
|
+
async with runtime.lock:
|
|
836
|
+
# Check if already in assistant mode (handling error state corrections)
|
|
837
|
+
if "is_assistant_mode_command" in runtime.chat_session.cme_workflow.context:
|
|
838
|
+
# Already in assistant mode - pass message as-is (no '/' prefix)
|
|
839
|
+
# User is providing corrections for ambiguity/misunderstanding/parameter errors
|
|
840
|
+
assistant_query = request.user_query
|
|
841
|
+
else:
|
|
842
|
+
# Starting new assistant command - prepend '/' to enter assistant mode
|
|
843
|
+
assistant_query = f"/{request.user_query.lstrip('/')}"
|
|
844
|
+
|
|
845
|
+
# Enqueue the message
|
|
846
|
+
runtime.chat_session.user_message_queue.put(assistant_query)
|
|
847
|
+
|
|
848
|
+
# Wait for output
|
|
849
|
+
command_output = await wait_for_command_output(runtime, request.timeout_seconds)
|
|
850
|
+
|
|
851
|
+
# Incrementally save conversation turns (without generating topic/summary)
|
|
852
|
+
save_conversation_incremental(runtime, extract_turns_from_history, logger)
|
|
853
|
+
|
|
854
|
+
traces = collect_trace_events(runtime, user_id=user_id)
|
|
855
|
+
response_data = command_output.model_dump()
|
|
856
|
+
if traces:
|
|
857
|
+
response_data["traces"] = traces
|
|
858
|
+
|
|
859
|
+
return JSONResponse(content=response_data)
|
|
860
|
+
|
|
861
|
+
except HTTPException:
|
|
862
|
+
raise
|
|
863
|
+
except Exception as e:
|
|
864
|
+
logger.error(f"Error in invoke_assistant for session {channel_id}: {e}")
|
|
865
|
+
traceback.print_exc()
|
|
866
|
+
raise HTTPException(
|
|
867
|
+
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
|
868
|
+
detail=f"Internal error in invoke_assistant() for channel_id: {channel_id}",
|
|
869
|
+
) from e
|
|
870
|
+
|
|
871
|
+
|
|
872
|
+
@app.post(
|
|
873
|
+
"/perform_action",
|
|
874
|
+
operation_id="perform_action",
|
|
875
|
+
response_model=None,
|
|
876
|
+
status_code=status.HTTP_200_OK,
|
|
877
|
+
responses={
|
|
878
|
+
200: {"description": "Action performed successfully"},
|
|
879
|
+
401: {"description": "Invalid or expired JWT token"},
|
|
880
|
+
404: {"description": "Session not found"},
|
|
881
|
+
409: {"description": "Concurrent turn already in progress"},
|
|
882
|
+
422: {"description": "Invalid action format"},
|
|
883
|
+
504: {"description": "Action execution timed out"}
|
|
884
|
+
}
|
|
885
|
+
)
|
|
886
|
+
async def perform_action(
|
|
887
|
+
request: PerformActionRequest,
|
|
888
|
+
session: SessionData = Depends(get_session_and_ensure_runtime)
|
|
889
|
+
) -> JSONResponse:
|
|
890
|
+
"""
|
|
891
|
+
Execute a specific workflow action directly (bypasses parameter extraction).
|
|
892
|
+
|
|
893
|
+
Requires a valid JWT access token in the Authorization header (Bearer token format).
|
|
894
|
+
"""
|
|
895
|
+
channel_id = session.channel_id
|
|
896
|
+
user_id = session.user_id
|
|
897
|
+
try:
|
|
898
|
+
runtime = await session_manager.get_session(channel_id)
|
|
899
|
+
if not runtime:
|
|
900
|
+
raise HTTPException(
|
|
901
|
+
status_code=status.HTTP_404_NOT_FOUND,
|
|
902
|
+
detail=f"User session not found: {channel_id}"
|
|
903
|
+
)
|
|
904
|
+
|
|
905
|
+
if runtime.lock.locked():
|
|
906
|
+
raise HTTPException(
|
|
907
|
+
status_code=status.HTTP_409_CONFLICT,
|
|
908
|
+
detail=f"A turn is already in progress for user: {channel_id}"
|
|
909
|
+
)
|
|
910
|
+
|
|
911
|
+
async with runtime.lock:
|
|
912
|
+
# Convert dict to fastworkflow.Action
|
|
913
|
+
try:
|
|
914
|
+
action = fastworkflow.Action(**request.action)
|
|
915
|
+
except Exception as e:
|
|
916
|
+
raise HTTPException(
|
|
917
|
+
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
|
918
|
+
detail=f"Invalid action format: {e}",
|
|
919
|
+
) from e
|
|
920
|
+
|
|
921
|
+
# Directly call _process_action to bypass parameter extraction
|
|
922
|
+
# This executes synchronously in the current thread (not via queue)
|
|
923
|
+
command_output = runtime.chat_session._process_action(action)
|
|
924
|
+
|
|
925
|
+
traces = collect_trace_events(runtime, user_id=user_id)
|
|
926
|
+
response_data = command_output.model_dump()
|
|
927
|
+
if traces:
|
|
928
|
+
response_data["traces"] = traces
|
|
929
|
+
|
|
930
|
+
return JSONResponse(content=response_data)
|
|
931
|
+
|
|
932
|
+
except HTTPException:
|
|
933
|
+
raise
|
|
934
|
+
except Exception as e:
|
|
935
|
+
logger.error(f"Error in perform_action for session {channel_id}: {e}")
|
|
936
|
+
traceback.print_exc()
|
|
937
|
+
raise HTTPException(
|
|
938
|
+
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
|
939
|
+
detail=f"Internal error in perform_action() for channel_id: {channel_id}",
|
|
940
|
+
) from e
|
|
941
|
+
|
|
942
|
+
|
|
943
|
+
@app.post(
|
|
944
|
+
"/new_conversation",
|
|
945
|
+
operation_id="new_conversation",
|
|
946
|
+
status_code=status.HTTP_200_OK,
|
|
947
|
+
responses={
|
|
948
|
+
200: {"description": "New conversation started successfully"},
|
|
949
|
+
401: {"description": "Invalid or expired JWT token"},
|
|
950
|
+
404: {"description": "Session not found"},
|
|
951
|
+
500: {"description": "Failed to generate topic/summary or persist conversation"}
|
|
952
|
+
}
|
|
953
|
+
)
|
|
954
|
+
async def new_conversation(
|
|
955
|
+
session: SessionData = Depends(get_session_and_ensure_runtime)
|
|
956
|
+
) -> dict[str, str]:
|
|
957
|
+
"""
|
|
958
|
+
Persist the current conversation and start a new one.
|
|
959
|
+
Generates topic and summary synchronously; on failure, does not rotate.
|
|
960
|
+
|
|
961
|
+
Requires a valid JWT access token in the Authorization header (Bearer token format).
|
|
962
|
+
"""
|
|
963
|
+
channel_id = session.channel_id
|
|
964
|
+
try:
|
|
965
|
+
runtime = await session_manager.get_session(channel_id)
|
|
966
|
+
if not runtime:
|
|
967
|
+
raise HTTPException(
|
|
968
|
+
status_code=status.HTTP_404_NOT_FOUND,
|
|
969
|
+
detail=f"User session not found: {channel_id}"
|
|
970
|
+
)
|
|
971
|
+
|
|
972
|
+
# Extract turns from chat_session conversation history
|
|
973
|
+
if turns := extract_turns_from_history(runtime.chat_session.conversation_history):
|
|
974
|
+
# Generate topic and summary synchronously (turns already saved incrementally)
|
|
975
|
+
topic, summary = generate_topic_and_summary(turns)
|
|
976
|
+
|
|
977
|
+
# Update topic/summary for the conversation (turns already persisted)
|
|
978
|
+
if runtime.active_conversation_id > 0:
|
|
979
|
+
conv_id = runtime.active_conversation_id
|
|
980
|
+
runtime.conversation_store.update_conversation_topic_summary(
|
|
981
|
+
conv_id, topic, summary
|
|
982
|
+
)
|
|
983
|
+
logger.info(f"Finalized conversation {conv_id} with topic and summary for session {channel_id}")
|
|
984
|
+
else:
|
|
985
|
+
# Edge case: conversation history exists but no active ID (shouldn't happen with incremental saves)
|
|
986
|
+
logger.warning(f"Conversation history exists but no active_conversation_id for session {channel_id}")
|
|
987
|
+
conv_id = runtime.conversation_store.save_conversation(topic, summary, turns)
|
|
988
|
+
logger.info(f"Created conversation {conv_id} for session {channel_id}")
|
|
989
|
+
|
|
990
|
+
# Reserve next conversation ID for the next conversation
|
|
991
|
+
next_id = runtime.conversation_store.reserve_next_conversation_id()
|
|
992
|
+
runtime.active_conversation_id = next_id
|
|
993
|
+
runtime.chat_session.clear_conversation_history()
|
|
994
|
+
|
|
995
|
+
logger.info(f"Ready for new conversation {runtime.active_conversation_id} for session {channel_id}")
|
|
996
|
+
return {"status": "ok"}
|
|
997
|
+
else:
|
|
998
|
+
# No turns to save, just clear history and start fresh
|
|
999
|
+
runtime.chat_session.clear_conversation_history()
|
|
1000
|
+
logger.info(f"No turns to save for session {channel_id}, cleared history")
|
|
1001
|
+
return {"status": "ok", "message": "No turns to save"}
|
|
1002
|
+
|
|
1003
|
+
except HTTPException:
|
|
1004
|
+
raise
|
|
1005
|
+
except Exception as e:
|
|
1006
|
+
logger.error(f"Error in new_conversation for session {channel_id}: {e}")
|
|
1007
|
+
traceback.print_exc()
|
|
1008
|
+
raise HTTPException(
|
|
1009
|
+
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
|
1010
|
+
detail=f"Internal error in new_conversation() for channel_id: {channel_id}",
|
|
1011
|
+
) from e
|
|
1012
|
+
|
|
1013
|
+
|
|
1014
|
+
@app.get(
|
|
1015
|
+
"/conversations",
|
|
1016
|
+
operation_id="get_all_conversations",
|
|
1017
|
+
response_model=list[ConversationSummary],
|
|
1018
|
+
status_code=status.HTTP_200_OK,
|
|
1019
|
+
responses={
|
|
1020
|
+
200: {"description": "Conversations retrieved successfully"},
|
|
1021
|
+
401: {"description": "Invalid or expired JWT token"},
|
|
1022
|
+
404: {"description": "Session not found"}
|
|
1023
|
+
}
|
|
1024
|
+
)
|
|
1025
|
+
async def list_conversations(
|
|
1026
|
+
limit: int = 20,
|
|
1027
|
+
session: SessionData = Depends(get_session_and_ensure_runtime)
|
|
1028
|
+
) -> list[ConversationSummary]:
|
|
1029
|
+
"""
|
|
1030
|
+
List conversations for a session, ordered by updated_at desc.
|
|
1031
|
+
Returns up to `limit` entries.
|
|
1032
|
+
|
|
1033
|
+
Requires a valid JWT access token in the Authorization header (Bearer token format).
|
|
1034
|
+
"""
|
|
1035
|
+
channel_id = session.channel_id
|
|
1036
|
+
try:
|
|
1037
|
+
runtime = await session_manager.get_session(channel_id)
|
|
1038
|
+
if not runtime:
|
|
1039
|
+
raise HTTPException(
|
|
1040
|
+
status_code=status.HTTP_404_NOT_FOUND,
|
|
1041
|
+
detail=f"User session not found: {channel_id}"
|
|
1042
|
+
)
|
|
1043
|
+
return runtime.conversation_store.list_conversations(limit)
|
|
1044
|
+
except HTTPException:
|
|
1045
|
+
raise
|
|
1046
|
+
except Exception as e:
|
|
1047
|
+
logger.error(f"Error in list_conversations for session {channel_id}: {e}")
|
|
1048
|
+
traceback.print_exc()
|
|
1049
|
+
raise HTTPException(
|
|
1050
|
+
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
|
1051
|
+
detail=f"Internal error in list_conversations() for channel_id: {channel_id}",
|
|
1052
|
+
) from e
|
|
1053
|
+
|
|
1054
|
+
|
|
1055
|
+
@app.post(
|
|
1056
|
+
"/post_feedback",
|
|
1057
|
+
operation_id="post_feedback",
|
|
1058
|
+
status_code=status.HTTP_200_OK,
|
|
1059
|
+
responses={
|
|
1060
|
+
200: {"description": "Feedback posted successfully"},
|
|
1061
|
+
401: {"description": "Invalid or expired JWT token"},
|
|
1062
|
+
404: {"description": "Session not found"},
|
|
1063
|
+
422: {"description": "No feedback provided or no turns to give feedback on"}
|
|
1064
|
+
}
|
|
1065
|
+
)
|
|
1066
|
+
async def post_feedback(
|
|
1067
|
+
request: PostFeedbackRequest,
|
|
1068
|
+
session: SessionData = Depends(get_session_and_ensure_runtime)
|
|
1069
|
+
) -> dict[str, str]:
|
|
1070
|
+
"""
|
|
1071
|
+
Post feedback on the latest turn of the active (in-memory) conversation.
|
|
1072
|
+
Feedback is attached to the turn in conversation_history and will be persisted
|
|
1073
|
+
when the conversation ends (on /new_conversation or shutdown).
|
|
1074
|
+
At least one of binary_or_numeric_score or nl_feedback must be provided.
|
|
1075
|
+
|
|
1076
|
+
Requires a valid JWT access token in the Authorization header (Bearer token format).
|
|
1077
|
+
"""
|
|
1078
|
+
channel_id = session.channel_id
|
|
1079
|
+
try:
|
|
1080
|
+
runtime = await session_manager.get_session(channel_id)
|
|
1081
|
+
if not runtime:
|
|
1082
|
+
raise HTTPException(
|
|
1083
|
+
status_code=status.HTTP_404_NOT_FOUND,
|
|
1084
|
+
detail=f"User session not found: {channel_id}"
|
|
1085
|
+
)
|
|
1086
|
+
|
|
1087
|
+
# Check if there are any in-memory turns to give feedback on
|
|
1088
|
+
if not runtime.chat_session.conversation_history.messages:
|
|
1089
|
+
raise HTTPException(
|
|
1090
|
+
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
|
1091
|
+
detail=f"No turns available to give feedback on for user: {channel_id}"
|
|
1092
|
+
)
|
|
1093
|
+
|
|
1094
|
+
# Update feedback on the last turn in the in-memory conversation history
|
|
1095
|
+
last_turn = runtime.chat_session.conversation_history.messages[-1]
|
|
1096
|
+
last_turn["feedback"] = {
|
|
1097
|
+
"binary_or_numeric_score": request.binary_or_numeric_score,
|
|
1098
|
+
"nl_feedback": request.nl_feedback,
|
|
1099
|
+
"timestamp": int(time.time() * 1000)
|
|
1100
|
+
}
|
|
1101
|
+
|
|
1102
|
+
# Incrementally save the updated turns with feedback
|
|
1103
|
+
save_conversation_incremental(runtime, extract_turns_from_history, logger)
|
|
1104
|
+
|
|
1105
|
+
logger.info(f"Added feedback to latest turn for session {channel_id}")
|
|
1106
|
+
return {"status": "ok"}
|
|
1107
|
+
|
|
1108
|
+
except HTTPException:
|
|
1109
|
+
raise
|
|
1110
|
+
except Exception as e:
|
|
1111
|
+
logger.error(f"Error in post_feedback for session {channel_id}: {e}")
|
|
1112
|
+
traceback.print_exc()
|
|
1113
|
+
raise HTTPException(
|
|
1114
|
+
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
|
1115
|
+
detail=f"Internal error in post_feedback() for channel_id: {channel_id}",
|
|
1116
|
+
) from e
|
|
1117
|
+
|
|
1118
|
+
|
|
1119
|
+
@app.post(
|
|
1120
|
+
"/activate_conversation",
|
|
1121
|
+
operation_id="activate_conversation",
|
|
1122
|
+
status_code=status.HTTP_200_OK,
|
|
1123
|
+
responses={
|
|
1124
|
+
200: {"description": "Conversation activated successfully"},
|
|
1125
|
+
401: {"description": "Invalid or expired JWT token"},
|
|
1126
|
+
404: {"description": "Session or conversation not found"}
|
|
1127
|
+
}
|
|
1128
|
+
)
|
|
1129
|
+
async def activate_conversation(
|
|
1130
|
+
request: ActivateConversationRequest,
|
|
1131
|
+
session: SessionData = Depends(get_session_and_ensure_runtime)
|
|
1132
|
+
) -> dict[str, str]:
|
|
1133
|
+
"""
|
|
1134
|
+
Activate a conversation by its conversation_id.
|
|
1135
|
+
|
|
1136
|
+
Requires a valid JWT access token in the Authorization header (Bearer token format).
|
|
1137
|
+
"""
|
|
1138
|
+
channel_id = session.channel_id
|
|
1139
|
+
try:
|
|
1140
|
+
runtime = await session_manager.get_session(channel_id)
|
|
1141
|
+
if not runtime:
|
|
1142
|
+
raise HTTPException(
|
|
1143
|
+
status_code=status.HTTP_404_NOT_FOUND,
|
|
1144
|
+
detail=f"User session not found: {channel_id}"
|
|
1145
|
+
)
|
|
1146
|
+
|
|
1147
|
+
# Get conversation by ID
|
|
1148
|
+
conv = runtime.conversation_store.get_conversation(request.conversation_id)
|
|
1149
|
+
if not conv:
|
|
1150
|
+
raise HTTPException(
|
|
1151
|
+
status_code=status.HTTP_404_NOT_FOUND,
|
|
1152
|
+
detail=f"Conversation not found with ID: {request.conversation_id}"
|
|
1153
|
+
)
|
|
1154
|
+
|
|
1155
|
+
runtime.active_conversation_id = request.conversation_id
|
|
1156
|
+
|
|
1157
|
+
# Restore conversation history to chat_session
|
|
1158
|
+
restored_history = restore_history_from_turns(conv["turns"])
|
|
1159
|
+
runtime.chat_session._conversation_history = restored_history
|
|
1160
|
+
logger.info(f"Activated conversation {request.conversation_id} for session {channel_id}")
|
|
1161
|
+
|
|
1162
|
+
return {"status": "ok"}
|
|
1163
|
+
|
|
1164
|
+
except HTTPException:
|
|
1165
|
+
raise
|
|
1166
|
+
except Exception as e:
|
|
1167
|
+
logger.error(f"Error in activate_conversation for session {channel_id}: {e}")
|
|
1168
|
+
traceback.print_exc()
|
|
1169
|
+
raise HTTPException(
|
|
1170
|
+
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
|
1171
|
+
detail=f"Internal error in activate_conversation() for channel_id: {channel_id}",
|
|
1172
|
+
) from e
|
|
1173
|
+
|
|
1174
|
+
|
|
1175
|
+
@app.post(
|
|
1176
|
+
"/admin/dump_all_conversations",
|
|
1177
|
+
operation_id="dump_all_conversations",
|
|
1178
|
+
status_code=status.HTTP_200_OK,
|
|
1179
|
+
responses={
|
|
1180
|
+
200: {"description": "Conversations dumped successfully"},
|
|
1181
|
+
500: {"description": "Failed to dump conversations"}
|
|
1182
|
+
}
|
|
1183
|
+
)
|
|
1184
|
+
async def dump_all_conversations(request: DumpConversationsRequest) -> dict[str, str]:
|
|
1185
|
+
"""
|
|
1186
|
+
Admin endpoint: dump all conversations from all sessions to a JSONL file.
|
|
1187
|
+
Scans all .rdb files in the base folder, not just active sessions.
|
|
1188
|
+
"""
|
|
1189
|
+
try:
|
|
1190
|
+
os.makedirs(request.output_folder, exist_ok=True)
|
|
1191
|
+
timestamp = int(time.time())
|
|
1192
|
+
output_file = os.path.join(request.output_folder, f"all_conversations_{timestamp}.jsonl")
|
|
1193
|
+
|
|
1194
|
+
# Resolve base folder using SPEEDDICT_FOLDERNAME/channel_conversations
|
|
1195
|
+
base_folder = get_channelconversations_dir()
|
|
1196
|
+
|
|
1197
|
+
all_conversations = []
|
|
1198
|
+
session_count = 0
|
|
1199
|
+
|
|
1200
|
+
# Scan the base folder for all .rdb files (all users, active or not)
|
|
1201
|
+
if os.path.isdir(base_folder):
|
|
1202
|
+
for filename in os.listdir(base_folder):
|
|
1203
|
+
if filename.endswith('.rdb'):
|
|
1204
|
+
# Extract channel_id from filename (format: <channel_id>.rdb)
|
|
1205
|
+
channel_id = filename[:-4] # Remove .rdb extension
|
|
1206
|
+
|
|
1207
|
+
# Create temporary ConversationStore for this user
|
|
1208
|
+
store = ConversationStore(channel_id, base_folder)
|
|
1209
|
+
user_convs = store.get_all_conversations_for_dump()
|
|
1210
|
+
all_conversations.extend(user_convs)
|
|
1211
|
+
session_count += 1
|
|
1212
|
+
|
|
1213
|
+
# Write to JSONL
|
|
1214
|
+
with open(output_file, 'w') as f:
|
|
1215
|
+
for conv in all_conversations:
|
|
1216
|
+
f.write(json.dumps(conv) + '\n')
|
|
1217
|
+
|
|
1218
|
+
logger.info(f"Dumped {len(all_conversations)} conversations from {session_count} users to {output_file}")
|
|
1219
|
+
return {"file_path": output_file}
|
|
1220
|
+
|
|
1221
|
+
except Exception as e:
|
|
1222
|
+
logger.error(f"Error in dump_all_conversations: {e}")
|
|
1223
|
+
traceback.print_exc()
|
|
1224
|
+
raise HTTPException(
|
|
1225
|
+
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
|
1226
|
+
detail="Failed to dump conversations",
|
|
1227
|
+
) from e
|
|
1228
|
+
|
|
1229
|
+
|
|
1230
|
+
@app.post(
|
|
1231
|
+
"/admin/generate_mcp_token",
|
|
1232
|
+
operation_id="generate_mcp_token",
|
|
1233
|
+
response_model=TokenResponse,
|
|
1234
|
+
status_code=status.HTTP_200_OK,
|
|
1235
|
+
responses={
|
|
1236
|
+
200: {"description": "MCP token generated successfully"},
|
|
1237
|
+
500: {"description": "Failed to generate token"}
|
|
1238
|
+
}
|
|
1239
|
+
)
|
|
1240
|
+
async def generate_mcp_token(request: GenerateMCPTokenRequest) -> TokenResponse:
|
|
1241
|
+
"""
|
|
1242
|
+
Admin endpoint: Generate a long-lived access token for MCP client configuration.
|
|
1243
|
+
|
|
1244
|
+
These tokens are meant to be configured in MCP client settings (e.g., Claude Desktop)
|
|
1245
|
+
and have extended expiration times (default 365 days) since they can't be easily refreshed.
|
|
1246
|
+
|
|
1247
|
+
Args:
|
|
1248
|
+
channel_id: Identifier for the MCP user/client
|
|
1249
|
+
expires_days: Token expiration in days (default: 365 days / 1 year)
|
|
1250
|
+
|
|
1251
|
+
Returns:
|
|
1252
|
+
TokenResponse with long-lived access_token (no refresh_token needed for MCP)
|
|
1253
|
+
|
|
1254
|
+
Note: This endpoint should be restricted to administrators only in production.
|
|
1255
|
+
"""
|
|
1256
|
+
try:
|
|
1257
|
+
# Generate long-lived access token with optional user_id
|
|
1258
|
+
access_token = create_access_token(request.channel_id, user_id=request.user_id, expires_days=request.expires_days)
|
|
1259
|
+
|
|
1260
|
+
logger.info(f"Generated MCP token for channel_id: {request.channel_id}, user_id: {request.user_id}, expires in {request.expires_days} days")
|
|
1261
|
+
|
|
1262
|
+
return TokenResponse(
|
|
1263
|
+
access_token=access_token,
|
|
1264
|
+
refresh_token="", # Not needed for MCP (long-lived token)
|
|
1265
|
+
token_type="bearer",
|
|
1266
|
+
expires_in=request.expires_days * 24 * 60 * 60 # Convert to seconds
|
|
1267
|
+
)
|
|
1268
|
+
|
|
1269
|
+
except Exception as e:
|
|
1270
|
+
logger.error(f"Error generating MCP token: {e}")
|
|
1271
|
+
traceback.print_exc()
|
|
1272
|
+
raise HTTPException(
|
|
1273
|
+
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
|
1274
|
+
detail="Failed to generate MCP token",
|
|
1275
|
+
) from e
|
|
1276
|
+
|
|
1277
|
+
|
|
1278
|
+
# =========================================================================
|
|
1279
|
+
# MCP Mount (tools over Streamable HTTP and optional SSE per session)
|
|
1280
|
+
# IMPORTANT: Must be called AFTER all endpoints are defined so fastapi-mcp
|
|
1281
|
+
# can discover and convert them to MCP tools automatically
|
|
1282
|
+
# =========================================================================
|
|
1283
|
+
|
|
1284
|
+
setup_mcp(
|
|
1285
|
+
app=app,
|
|
1286
|
+
session_manager=session_manager,
|
|
1287
|
+
)
|
|
1288
|
+
|
|
1289
|
+
# ============================================================================
|
|
1290
|
+
# Main
|
|
1291
|
+
# ============================================================================
|
|
1292
|
+
|
|
1293
|
+
def main():
|
|
1294
|
+
"""Entry point for the FastAPI MCP server."""
|
|
1295
|
+
host = ARGS.host if hasattr(ARGS, 'host') else "0.0.0.0"
|
|
1296
|
+
port = ARGS.port if hasattr(ARGS, 'port') else 8000
|
|
1297
|
+
uvicorn.run(app, host=host, port=port)
|
|
1298
|
+
|
|
1299
|
+
if __name__ == "__main__":
|
|
1300
|
+
main()
|