fastworkflow 2.16.0__py3-none-any.whl → 2.17.1__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.

Potentially problematic release.


This version of fastworkflow might be problematic. Click here for more details.

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