fastworkflow 2.15.5__py3-none-any.whl → 2.17.13__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (42) hide show
  1. fastworkflow/_workflows/command_metadata_extraction/_commands/ErrorCorrection/you_misunderstood.py +1 -1
  2. fastworkflow/_workflows/command_metadata_extraction/_commands/IntentDetection/what_can_i_do.py +16 -2
  3. fastworkflow/_workflows/command_metadata_extraction/_commands/wildcard.py +27 -570
  4. fastworkflow/_workflows/command_metadata_extraction/intent_detection.py +360 -0
  5. fastworkflow/_workflows/command_metadata_extraction/parameter_extraction.py +411 -0
  6. fastworkflow/chat_session.py +379 -206
  7. fastworkflow/cli.py +80 -165
  8. fastworkflow/command_context_model.py +73 -7
  9. fastworkflow/command_executor.py +14 -5
  10. fastworkflow/command_metadata_api.py +106 -6
  11. fastworkflow/examples/fastworkflow.env +2 -1
  12. fastworkflow/examples/fastworkflow.passwords.env +2 -1
  13. fastworkflow/examples/retail_workflow/_commands/exchange_delivered_order_items.py +32 -3
  14. fastworkflow/examples/retail_workflow/_commands/find_user_id_by_email.py +6 -5
  15. fastworkflow/examples/retail_workflow/_commands/modify_pending_order_items.py +32 -3
  16. fastworkflow/examples/retail_workflow/_commands/return_delivered_order_items.py +13 -2
  17. fastworkflow/examples/retail_workflow/_commands/transfer_to_human_agents.py +1 -1
  18. fastworkflow/intent_clarification_agent.py +131 -0
  19. fastworkflow/mcp_server.py +3 -3
  20. fastworkflow/run/__main__.py +33 -40
  21. fastworkflow/run_fastapi_mcp/README.md +373 -0
  22. fastworkflow/run_fastapi_mcp/__main__.py +1300 -0
  23. fastworkflow/run_fastapi_mcp/conversation_store.py +391 -0
  24. fastworkflow/run_fastapi_mcp/jwt_manager.py +341 -0
  25. fastworkflow/run_fastapi_mcp/mcp_specific.py +103 -0
  26. fastworkflow/run_fastapi_mcp/redoc_2_standalone_html.py +40 -0
  27. fastworkflow/run_fastapi_mcp/utils.py +517 -0
  28. fastworkflow/train/__main__.py +1 -1
  29. fastworkflow/utils/chat_adapter.py +99 -0
  30. fastworkflow/utils/python_utils.py +4 -4
  31. fastworkflow/utils/react.py +258 -0
  32. fastworkflow/utils/signatures.py +338 -139
  33. fastworkflow/workflow.py +1 -5
  34. fastworkflow/workflow_agent.py +185 -133
  35. {fastworkflow-2.15.5.dist-info → fastworkflow-2.17.13.dist-info}/METADATA +16 -18
  36. {fastworkflow-2.15.5.dist-info → fastworkflow-2.17.13.dist-info}/RECORD +40 -30
  37. fastworkflow/run_agent/__main__.py +0 -294
  38. fastworkflow/run_agent/agent_module.py +0 -194
  39. /fastworkflow/{run_agent → run_fastapi_mcp}/__init__.py +0 -0
  40. {fastworkflow-2.15.5.dist-info → fastworkflow-2.17.13.dist-info}/LICENSE +0 -0
  41. {fastworkflow-2.15.5.dist-info → fastworkflow-2.17.13.dist-info}/WHEEL +0 -0
  42. {fastworkflow-2.15.5.dist-info → fastworkflow-2.17.13.dist-info}/entry_points.txt +0 -0
@@ -0,0 +1,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()