fastworkflow 2.17.9__py3-none-any.whl → 2.17.10__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- fastworkflow/chat_session.py +88 -30
- fastworkflow/run_fastapi_mcp/README.md +58 -40
- fastworkflow/run_fastapi_mcp/__main__.py +197 -114
- fastworkflow/run_fastapi_mcp/conversation_store.py +4 -4
- fastworkflow/run_fastapi_mcp/jwt_manager.py +24 -14
- fastworkflow/run_fastapi_mcp/utils.py +116 -52
- {fastworkflow-2.17.9.dist-info → fastworkflow-2.17.10.dist-info}/METADATA +1 -1
- {fastworkflow-2.17.9.dist-info → fastworkflow-2.17.10.dist-info}/RECORD +11 -11
- {fastworkflow-2.17.9.dist-info → fastworkflow-2.17.10.dist-info}/LICENSE +0 -0
- {fastworkflow-2.17.9.dist-info → fastworkflow-2.17.10.dist-info}/WHEEL +0 -0
- {fastworkflow-2.17.9.dist-info → fastworkflow-2.17.10.dist-info}/entry_points.txt +0 -0
|
@@ -40,11 +40,12 @@ from fastapi.responses import HTMLResponse, JSONResponse, StreamingResponse
|
|
|
40
40
|
|
|
41
41
|
from .mcp_specific import setup_mcp
|
|
42
42
|
from .utils import (
|
|
43
|
-
|
|
44
|
-
|
|
43
|
+
get_channelconversations_dir,
|
|
44
|
+
ChannelSessionManager,
|
|
45
45
|
save_conversation_incremental,
|
|
46
46
|
InitializationRequest,
|
|
47
47
|
TokenResponse,
|
|
48
|
+
InitializeResponse,
|
|
48
49
|
SessionData,
|
|
49
50
|
InvokeRequest,
|
|
50
51
|
PerformActionRequest,
|
|
@@ -54,6 +55,7 @@ from .utils import (
|
|
|
54
55
|
GenerateMCPTokenRequest,
|
|
55
56
|
wait_for_command_output,
|
|
56
57
|
collect_trace_events,
|
|
58
|
+
collect_trace_events_async,
|
|
57
59
|
get_session_from_jwt,
|
|
58
60
|
ensure_user_runtime_exists
|
|
59
61
|
)
|
|
@@ -79,7 +81,7 @@ from .conversation_store import (
|
|
|
79
81
|
# ============================================================================
|
|
80
82
|
|
|
81
83
|
# Global session manager
|
|
82
|
-
session_manager =
|
|
84
|
+
session_manager = ChannelSessionManager()
|
|
83
85
|
|
|
84
86
|
|
|
85
87
|
# ============================================================================
|
|
@@ -114,14 +116,14 @@ async def get_session_and_ensure_runtime(
|
|
|
114
116
|
```python
|
|
115
117
|
@app.post("/endpoint")
|
|
116
118
|
async def endpoint(session: SessionData = Depends(get_session_and_ensure_runtime)):
|
|
117
|
-
# session.
|
|
118
|
-
runtime = await session_manager.get_session(session.
|
|
119
|
+
# session.channel_id can now safely be used with session_manager
|
|
120
|
+
runtime = await session_manager.get_session(session.channel_id)
|
|
119
121
|
# runtime is guaranteed to exist
|
|
120
122
|
```
|
|
121
123
|
"""
|
|
122
124
|
# Ensure the user runtime exists (creates if missing)
|
|
123
125
|
await ensure_user_runtime_exists(
|
|
124
|
-
|
|
126
|
+
channel_id=session.channel_id,
|
|
125
127
|
session_manager=session_manager,
|
|
126
128
|
workflow_path=ARGS.workflow_path,
|
|
127
129
|
context=json.loads(ARGS.context) if ARGS.context else None,
|
|
@@ -154,31 +156,31 @@ async def lifespan(_app: FastAPI):
|
|
|
154
156
|
# Configure JWT verification mode based on CLI parameter
|
|
155
157
|
set_jwt_verification_mode(ARGS.expect_encrypted_jwt)
|
|
156
158
|
|
|
157
|
-
async def
|
|
159
|
+
async def _active_turn_channel_ids() -> list[str]:
|
|
158
160
|
active: list[str] = []
|
|
159
|
-
for
|
|
160
|
-
rt = await session_manager.get_session(
|
|
161
|
+
for channel_id in list(session_manager._sessions.keys()):
|
|
162
|
+
rt = await session_manager.get_session(channel_id)
|
|
161
163
|
if rt and rt.lock.locked():
|
|
162
|
-
active.append(
|
|
164
|
+
active.append(channel_id)
|
|
163
165
|
return active
|
|
164
166
|
|
|
165
167
|
async def wait_for_active_turns_to_complete(max_wait_seconds: int) -> None:
|
|
166
168
|
logger.info(f"Waiting up to {max_wait_seconds}s for active turns to complete...")
|
|
167
169
|
start_time = time.time()
|
|
168
170
|
while time.time() - start_time < max_wait_seconds:
|
|
169
|
-
active_turns = await
|
|
171
|
+
active_turns = await _active_turn_channel_ids()
|
|
170
172
|
if not active_turns:
|
|
171
173
|
logger.info("All turns completed, shutting down gracefully")
|
|
172
174
|
return
|
|
173
175
|
logger.debug(f"Waiting for {len(active_turns)} active turns: {active_turns}")
|
|
174
176
|
await asyncio.sleep(0.5)
|
|
175
|
-
remaining = await
|
|
177
|
+
remaining = await _active_turn_channel_ids()
|
|
176
178
|
logger.warning(f"Shutdown timeout reached with {len(remaining)} turns still active")
|
|
177
179
|
|
|
178
180
|
async def finalize_conversations_on_shutdown() -> None:
|
|
179
181
|
logger.info("Finalizing conversations with topic and summary...")
|
|
180
|
-
for
|
|
181
|
-
runtime = await session_manager.get_session(
|
|
182
|
+
for channel_id in list(session_manager._sessions.keys()):
|
|
183
|
+
runtime = await session_manager.get_session(channel_id)
|
|
182
184
|
if not runtime:
|
|
183
185
|
continue
|
|
184
186
|
if turns := extract_turns_from_history(runtime.chat_session.conversation_history):
|
|
@@ -188,17 +190,17 @@ async def lifespan(_app: FastAPI):
|
|
|
188
190
|
runtime.conversation_store.update_conversation_topic_summary(
|
|
189
191
|
runtime.active_conversation_id, topic, summary
|
|
190
192
|
)
|
|
191
|
-
logger.info(f"Finalized conversation {runtime.active_conversation_id} for user {
|
|
193
|
+
logger.info(f"Finalized conversation {runtime.active_conversation_id} for user {channel_id} during shutdown")
|
|
192
194
|
else:
|
|
193
|
-
logger.warning(f"Conversation history exists but no active_conversation_id for user {
|
|
195
|
+
logger.warning(f"Conversation history exists but no active_conversation_id for user {channel_id} during shutdown")
|
|
194
196
|
conv_id = runtime.conversation_store.save_conversation(topic, summary, turns)
|
|
195
|
-
logger.info(f"Created conversation {conv_id} for user {
|
|
197
|
+
logger.info(f"Created conversation {conv_id} for user {channel_id} during shutdown")
|
|
196
198
|
except Exception as e:
|
|
197
|
-
logger.error(f"Failed to finalize conversation for user {
|
|
199
|
+
logger.error(f"Failed to finalize conversation for user {channel_id} during shutdown: {e}")
|
|
198
200
|
|
|
199
201
|
async def stop_all_chat_sessions() -> None:
|
|
200
|
-
for
|
|
201
|
-
runtime = await session_manager.get_session(
|
|
202
|
+
for channel_id in list(session_manager._sessions.keys()):
|
|
203
|
+
runtime = await session_manager.get_session(channel_id)
|
|
202
204
|
if runtime:
|
|
203
205
|
runtime.chat_session.stop_workflow()
|
|
204
206
|
|
|
@@ -311,76 +313,146 @@ async def root():
|
|
|
311
313
|
@app.post(
|
|
312
314
|
"/initialize",
|
|
313
315
|
operation_id="rest_initialize",
|
|
314
|
-
response_model=
|
|
316
|
+
response_model=InitializeResponse,
|
|
315
317
|
status_code=status.HTTP_200_OK,
|
|
316
318
|
responses={
|
|
317
|
-
200: {"description": "Session successfully initialized, JWT tokens returned"},
|
|
318
|
-
400: {"description": "Both startup_command and startup_action provided"},
|
|
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"},
|
|
319
321
|
422: {"description": "Invalid paths or missing env vars"},
|
|
320
322
|
500: {"description": "Internal error during initialization"}
|
|
321
323
|
}
|
|
322
324
|
)
|
|
323
|
-
async def initialize(request: InitializationRequest) ->
|
|
325
|
+
async def initialize(request: InitializationRequest) -> InitializeResponse:
|
|
324
326
|
"""
|
|
325
|
-
Initialize a FastWorkflow session for a
|
|
327
|
+
Initialize a FastWorkflow session for a channel.
|
|
326
328
|
Creates or resumes a ChatSession and starts the workflow.
|
|
329
|
+
Optionally executes a startup command/action and returns its output.
|
|
327
330
|
"""
|
|
328
331
|
try:
|
|
332
|
+
channel_id = request.channel_id
|
|
329
333
|
user_id = request.user_id
|
|
330
|
-
logger.info(f"Initializing session for user_id: {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
|
+
)
|
|
331
349
|
|
|
332
350
|
# Check if user already has an active session
|
|
333
|
-
existing_runtime = await session_manager.get_session(
|
|
351
|
+
existing_runtime = await session_manager.get_session(channel_id)
|
|
334
352
|
if existing_runtime:
|
|
335
|
-
logger.info(f"Session for
|
|
353
|
+
logger.info(f"Session for channel_id {channel_id} already exists, generating new tokens")
|
|
336
354
|
|
|
337
355
|
# Generate new JWT tokens for existing session
|
|
338
|
-
access_token = create_access_token(user_id)
|
|
339
|
-
refresh_token = create_refresh_token(user_id)
|
|
356
|
+
access_token = create_access_token(channel_id, user_id)
|
|
357
|
+
refresh_token = create_refresh_token(channel_id, user_id)
|
|
340
358
|
|
|
341
|
-
return
|
|
359
|
+
return InitializeResponse(
|
|
342
360
|
access_token=access_token,
|
|
343
361
|
refresh_token=refresh_token,
|
|
344
362
|
token_type="bearer",
|
|
345
363
|
expires_in=JWT_ACCESS_TOKEN_EXPIRE_MINUTES * 60 # Convert to seconds
|
|
346
364
|
)
|
|
347
365
|
|
|
348
|
-
# Prepare startup action if provided
|
|
366
|
+
# Prepare startup action if provided in request (takes precedence over CLI args)
|
|
349
367
|
startup_action = None
|
|
350
|
-
|
|
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:
|
|
351
373
|
startup_action = fastworkflow.Action(**json.loads(ARGS.startup_action))
|
|
352
374
|
|
|
353
375
|
# Use the modular helper function to create the session
|
|
354
|
-
# This ensures consistent session creation logic across the application
|
|
355
376
|
await ensure_user_runtime_exists(
|
|
356
|
-
|
|
377
|
+
channel_id=channel_id,
|
|
357
378
|
session_manager=session_manager,
|
|
358
379
|
workflow_path=ARGS.workflow_path,
|
|
359
380
|
context=json.loads(ARGS.context) if ARGS.context else None,
|
|
360
|
-
startup_command=
|
|
361
|
-
startup_action=
|
|
381
|
+
startup_command=None, # Don't execute during session creation
|
|
382
|
+
startup_action=None, # Don't execute during session creation
|
|
362
383
|
stream_format=(request.stream_format if request.stream_format in ("ndjson", "sse") else "ndjson")
|
|
363
384
|
)
|
|
364
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
|
+
|
|
365
436
|
# Generate JWT tokens
|
|
366
|
-
access_token = create_access_token(user_id)
|
|
367
|
-
refresh_token = create_refresh_token(user_id)
|
|
437
|
+
access_token = create_access_token(channel_id, user_id)
|
|
438
|
+
refresh_token = create_refresh_token(channel_id, user_id)
|
|
368
439
|
|
|
369
|
-
return
|
|
440
|
+
return InitializeResponse(
|
|
370
441
|
access_token=access_token,
|
|
371
442
|
refresh_token=refresh_token,
|
|
372
443
|
token_type="bearer",
|
|
373
|
-
expires_in=JWT_ACCESS_TOKEN_EXPIRE_MINUTES * 60 # Convert to seconds
|
|
444
|
+
expires_in=JWT_ACCESS_TOKEN_EXPIRE_MINUTES * 60, # Convert to seconds
|
|
445
|
+
startup_output=startup_output
|
|
374
446
|
)
|
|
375
447
|
|
|
376
448
|
except HTTPException:
|
|
377
449
|
raise
|
|
378
450
|
except Exception as e:
|
|
379
|
-
logger.error(f"Error initializing session for
|
|
451
|
+
logger.error(f"Error initializing session for channel_id: {request.channel_id}: {e}")
|
|
380
452
|
traceback.print_exc()
|
|
381
453
|
raise HTTPException(
|
|
382
454
|
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
|
383
|
-
detail=f"Internal error in initialize() for
|
|
455
|
+
detail=f"Internal error in initialize() for channel_id: {request.channel_id}",
|
|
384
456
|
) from e
|
|
385
457
|
|
|
386
458
|
|
|
@@ -426,22 +498,23 @@ async def refresh_token(
|
|
|
426
498
|
headers={"WWW-Authenticate": "Bearer"},
|
|
427
499
|
) from e
|
|
428
500
|
|
|
429
|
-
# Extract user_id from payload
|
|
430
|
-
|
|
501
|
+
# Extract channel_id and optional user_id from payload
|
|
502
|
+
channel_id = payload["sub"]
|
|
503
|
+
user_id = payload.get("uid")
|
|
431
504
|
|
|
432
505
|
# Verify session still exists
|
|
433
|
-
runtime = await session_manager.get_session(
|
|
506
|
+
runtime = await session_manager.get_session(channel_id)
|
|
434
507
|
if not runtime:
|
|
435
508
|
raise HTTPException(
|
|
436
509
|
status_code=status.HTTP_404_NOT_FOUND,
|
|
437
|
-
detail=f"User session not found: {
|
|
510
|
+
detail=f"User session not found: {channel_id} (may have been cleaned up)"
|
|
438
511
|
)
|
|
439
512
|
|
|
440
|
-
# Generate new tokens
|
|
441
|
-
new_access_token = create_access_token(user_id)
|
|
442
|
-
new_refresh_token = create_refresh_token(user_id)
|
|
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)
|
|
443
516
|
|
|
444
|
-
logger.info(f"Refreshed tokens for user_id: {user_id}")
|
|
517
|
+
logger.info(f"Refreshed tokens for channel_id: {channel_id}, user_id: {user_id}")
|
|
445
518
|
|
|
446
519
|
return TokenResponse(
|
|
447
520
|
access_token=new_access_token,
|
|
@@ -484,20 +557,21 @@ async def invoke_agent(
|
|
|
484
557
|
|
|
485
558
|
Requires a valid JWT access token in the Authorization header (Bearer token format).
|
|
486
559
|
"""
|
|
560
|
+
channel_id = session.channel_id
|
|
487
561
|
user_id = session.user_id
|
|
488
562
|
try:
|
|
489
|
-
runtime = await session_manager.get_session(
|
|
563
|
+
runtime = await session_manager.get_session(channel_id)
|
|
490
564
|
if not runtime:
|
|
491
565
|
raise HTTPException(
|
|
492
566
|
status_code=status.HTTP_404_NOT_FOUND,
|
|
493
|
-
detail=f"User session not found: {
|
|
567
|
+
detail=f"User session not found: {channel_id}"
|
|
494
568
|
)
|
|
495
569
|
|
|
496
570
|
# Serialize turns per user
|
|
497
571
|
if runtime.lock.locked():
|
|
498
572
|
raise HTTPException(
|
|
499
573
|
status_code=status.HTTP_409_CONFLICT,
|
|
500
|
-
detail=f"A turn is already in progress for user: {
|
|
574
|
+
detail=f"A turn is already in progress for user: {channel_id}"
|
|
501
575
|
)
|
|
502
576
|
|
|
503
577
|
async with runtime.lock:
|
|
@@ -513,7 +587,7 @@ async def invoke_agent(
|
|
|
513
587
|
# Incrementally save conversation turns (without generating topic/summary)
|
|
514
588
|
save_conversation_incremental(runtime, extract_turns_from_history, logger)
|
|
515
589
|
|
|
516
|
-
traces = collect_trace_events(runtime)
|
|
590
|
+
traces = collect_trace_events(runtime, user_id=user_id)
|
|
517
591
|
# Build response with traces
|
|
518
592
|
response_data = command_output.model_dump()
|
|
519
593
|
if traces:
|
|
@@ -524,11 +598,11 @@ async def invoke_agent(
|
|
|
524
598
|
except HTTPException:
|
|
525
599
|
raise
|
|
526
600
|
except Exception as e:
|
|
527
|
-
logger.error(f"Error in invoke_agent for user {
|
|
601
|
+
logger.error(f"Error in invoke_agent for user {channel_id}: {e}")
|
|
528
602
|
traceback.print_exc()
|
|
529
603
|
raise HTTPException(
|
|
530
604
|
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
|
531
|
-
detail=f"Internal error in invoke_agent() for
|
|
605
|
+
detail=f"Internal error in invoke_agent() for channel_id: {channel_id}",
|
|
532
606
|
) from e
|
|
533
607
|
|
|
534
608
|
|
|
@@ -563,20 +637,21 @@ async def invoke_agent_stream(
|
|
|
563
637
|
Requires a valid JWT access token in the Authorization header (Bearer token format).
|
|
564
638
|
Exposed as 'invoke_agent' tool for MCP clients (who don't need JWT auth).
|
|
565
639
|
"""
|
|
640
|
+
channel_id = session.channel_id
|
|
566
641
|
user_id = session.user_id
|
|
567
642
|
|
|
568
643
|
# Get runtime and validate session exists
|
|
569
|
-
runtime = await session_manager.get_session(
|
|
644
|
+
runtime = await session_manager.get_session(channel_id)
|
|
570
645
|
if not runtime:
|
|
571
646
|
return JSONResponse(
|
|
572
647
|
status_code=status.HTTP_404_NOT_FOUND,
|
|
573
|
-
content={"detail": f"User session not found: {
|
|
648
|
+
content={"detail": f"User session not found: {channel_id}"}
|
|
574
649
|
)
|
|
575
650
|
|
|
576
651
|
async def ndjson_stream():
|
|
577
652
|
try:
|
|
578
653
|
if runtime.lock.locked():
|
|
579
|
-
yield {"type": "error", "data": {"detail": f"A turn is already in progress for user: {
|
|
654
|
+
yield {"type": "error", "data": {"detail": f"A turn is already in progress for user: {channel_id}"}}
|
|
580
655
|
return
|
|
581
656
|
|
|
582
657
|
async with runtime.lock:
|
|
@@ -597,6 +672,8 @@ async def invoke_agent_stream(
|
|
|
597
672
|
"success": evt.success,
|
|
598
673
|
"timestamp_ms": evt.timestamp_ms,
|
|
599
674
|
}
|
|
675
|
+
if user_id is not None:
|
|
676
|
+
trace_json["user_id"] = user_id
|
|
600
677
|
yield {"type": "trace", "data": trace_json}
|
|
601
678
|
except queue.Empty:
|
|
602
679
|
break
|
|
@@ -621,6 +698,8 @@ async def invoke_agent_stream(
|
|
|
621
698
|
"success": evt.success,
|
|
622
699
|
"timestamp_ms": evt.timestamp_ms,
|
|
623
700
|
}
|
|
701
|
+
if user_id is not None:
|
|
702
|
+
trace_json["user_id"] = user_id
|
|
624
703
|
yield {"type": "trace", "data": trace_json}
|
|
625
704
|
except queue.Empty:
|
|
626
705
|
break
|
|
@@ -633,14 +712,14 @@ async def invoke_agent_stream(
|
|
|
633
712
|
yield {"type": "output", "data": command_output.model_dump()}
|
|
634
713
|
|
|
635
714
|
except Exception as e:
|
|
636
|
-
logger.error(f"Error in invoke_agent_stream for user {
|
|
715
|
+
logger.error(f"Error in invoke_agent_stream for user {channel_id}: {e}")
|
|
637
716
|
traceback.print_exc()
|
|
638
|
-
yield {"type": "error", "data": {"detail": f"Internal error in invoke_agent_stream() for
|
|
717
|
+
yield {"type": "error", "data": {"detail": f"Internal error in invoke_agent_stream() for channel_id: {channel_id}"}}
|
|
639
718
|
|
|
640
719
|
async def sse_stream():
|
|
641
720
|
try:
|
|
642
721
|
if runtime.lock.locked():
|
|
643
|
-
yield "event: error\n" + f"data: {json.dumps({'detail': f'A turn is already in progress for user: {
|
|
722
|
+
yield "event: error\n" + f"data: {json.dumps({'detail': f'A turn is already in progress for user: {channel_id}'})}\n\n"
|
|
644
723
|
return
|
|
645
724
|
|
|
646
725
|
async with runtime.lock:
|
|
@@ -656,6 +735,8 @@ async def invoke_agent_stream(
|
|
|
656
735
|
"success": evt.success,
|
|
657
736
|
"timestamp_ms": evt.timestamp_ms,
|
|
658
737
|
}
|
|
738
|
+
if user_id is not None:
|
|
739
|
+
trace_data["user_id"] = user_id
|
|
659
740
|
return f"event: trace\ndata: {json.dumps(trace_data)}\n\n"
|
|
660
741
|
|
|
661
742
|
start_time = time.time()
|
|
@@ -692,9 +773,9 @@ async def invoke_agent_stream(
|
|
|
692
773
|
yield "event: output\n" + f"data: {json.dumps(command_output.model_dump())}\n\n"
|
|
693
774
|
|
|
694
775
|
except Exception as e:
|
|
695
|
-
logger.error(f"Error in invoke_agent_stream SSE for user {
|
|
776
|
+
logger.error(f"Error in invoke_agent_stream SSE for user {channel_id}: {e}")
|
|
696
777
|
traceback.print_exc()
|
|
697
|
-
yield "event: error\n" + f"data: {json.dumps({'detail': f'Internal error in invoke_agent_stream() for
|
|
778
|
+
yield "event: error\n" + f"data: {json.dumps({'detail': f'Internal error in invoke_agent_stream() for channel_id: {channel_id}'})}\n\n"
|
|
698
779
|
|
|
699
780
|
# Route to appropriate stream format
|
|
700
781
|
if runtime.stream_format == "sse":
|
|
@@ -735,19 +816,20 @@ async def invoke_assistant(
|
|
|
735
816
|
|
|
736
817
|
Requires a valid JWT access token in the Authorization header (Bearer token format).
|
|
737
818
|
"""
|
|
819
|
+
channel_id = session.channel_id
|
|
738
820
|
user_id = session.user_id
|
|
739
821
|
try:
|
|
740
|
-
runtime = await session_manager.get_session(
|
|
822
|
+
runtime = await session_manager.get_session(channel_id)
|
|
741
823
|
if not runtime:
|
|
742
824
|
raise HTTPException(
|
|
743
825
|
status_code=status.HTTP_404_NOT_FOUND,
|
|
744
|
-
detail=f"User session not found: {
|
|
826
|
+
detail=f"User session not found: {channel_id}"
|
|
745
827
|
)
|
|
746
828
|
|
|
747
829
|
if runtime.lock.locked():
|
|
748
830
|
raise HTTPException(
|
|
749
831
|
status_code=status.HTTP_409_CONFLICT,
|
|
750
|
-
detail=f"A turn is already in progress for user: {
|
|
832
|
+
detail=f"A turn is already in progress for user: {channel_id}"
|
|
751
833
|
)
|
|
752
834
|
|
|
753
835
|
async with runtime.lock:
|
|
@@ -769,7 +851,7 @@ async def invoke_assistant(
|
|
|
769
851
|
# Incrementally save conversation turns (without generating topic/summary)
|
|
770
852
|
save_conversation_incremental(runtime, extract_turns_from_history, logger)
|
|
771
853
|
|
|
772
|
-
traces = collect_trace_events(runtime)
|
|
854
|
+
traces = collect_trace_events(runtime, user_id=user_id)
|
|
773
855
|
response_data = command_output.model_dump()
|
|
774
856
|
if traces:
|
|
775
857
|
response_data["traces"] = traces
|
|
@@ -779,11 +861,11 @@ async def invoke_assistant(
|
|
|
779
861
|
except HTTPException:
|
|
780
862
|
raise
|
|
781
863
|
except Exception as e:
|
|
782
|
-
logger.error(f"Error in invoke_assistant for session {
|
|
864
|
+
logger.error(f"Error in invoke_assistant for session {channel_id}: {e}")
|
|
783
865
|
traceback.print_exc()
|
|
784
866
|
raise HTTPException(
|
|
785
867
|
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
|
786
|
-
detail=f"Internal error in invoke_assistant() for
|
|
868
|
+
detail=f"Internal error in invoke_assistant() for channel_id: {channel_id}",
|
|
787
869
|
) from e
|
|
788
870
|
|
|
789
871
|
|
|
@@ -810,19 +892,20 @@ async def perform_action(
|
|
|
810
892
|
|
|
811
893
|
Requires a valid JWT access token in the Authorization header (Bearer token format).
|
|
812
894
|
"""
|
|
895
|
+
channel_id = session.channel_id
|
|
813
896
|
user_id = session.user_id
|
|
814
897
|
try:
|
|
815
|
-
runtime = await session_manager.get_session(
|
|
898
|
+
runtime = await session_manager.get_session(channel_id)
|
|
816
899
|
if not runtime:
|
|
817
900
|
raise HTTPException(
|
|
818
901
|
status_code=status.HTTP_404_NOT_FOUND,
|
|
819
|
-
detail=f"User session not found: {
|
|
902
|
+
detail=f"User session not found: {channel_id}"
|
|
820
903
|
)
|
|
821
904
|
|
|
822
905
|
if runtime.lock.locked():
|
|
823
906
|
raise HTTPException(
|
|
824
907
|
status_code=status.HTTP_409_CONFLICT,
|
|
825
|
-
detail=f"A turn is already in progress for user: {
|
|
908
|
+
detail=f"A turn is already in progress for user: {channel_id}"
|
|
826
909
|
)
|
|
827
910
|
|
|
828
911
|
async with runtime.lock:
|
|
@@ -839,7 +922,7 @@ async def perform_action(
|
|
|
839
922
|
# This executes synchronously in the current thread (not via queue)
|
|
840
923
|
command_output = runtime.chat_session._process_action(action)
|
|
841
924
|
|
|
842
|
-
traces = collect_trace_events(runtime)
|
|
925
|
+
traces = collect_trace_events(runtime, user_id=user_id)
|
|
843
926
|
response_data = command_output.model_dump()
|
|
844
927
|
if traces:
|
|
845
928
|
response_data["traces"] = traces
|
|
@@ -849,11 +932,11 @@ async def perform_action(
|
|
|
849
932
|
except HTTPException:
|
|
850
933
|
raise
|
|
851
934
|
except Exception as e:
|
|
852
|
-
logger.error(f"Error in perform_action for session {
|
|
935
|
+
logger.error(f"Error in perform_action for session {channel_id}: {e}")
|
|
853
936
|
traceback.print_exc()
|
|
854
937
|
raise HTTPException(
|
|
855
938
|
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
|
856
|
-
detail=f"Internal error in perform_action() for
|
|
939
|
+
detail=f"Internal error in perform_action() for channel_id: {channel_id}",
|
|
857
940
|
) from e
|
|
858
941
|
|
|
859
942
|
|
|
@@ -877,13 +960,13 @@ async def new_conversation(
|
|
|
877
960
|
|
|
878
961
|
Requires a valid JWT access token in the Authorization header (Bearer token format).
|
|
879
962
|
"""
|
|
880
|
-
|
|
963
|
+
channel_id = session.channel_id
|
|
881
964
|
try:
|
|
882
|
-
runtime = await session_manager.get_session(
|
|
965
|
+
runtime = await session_manager.get_session(channel_id)
|
|
883
966
|
if not runtime:
|
|
884
967
|
raise HTTPException(
|
|
885
968
|
status_code=status.HTTP_404_NOT_FOUND,
|
|
886
|
-
detail=f"User session not found: {
|
|
969
|
+
detail=f"User session not found: {channel_id}"
|
|
887
970
|
)
|
|
888
971
|
|
|
889
972
|
# Extract turns from chat_session conversation history
|
|
@@ -897,34 +980,34 @@ async def new_conversation(
|
|
|
897
980
|
runtime.conversation_store.update_conversation_topic_summary(
|
|
898
981
|
conv_id, topic, summary
|
|
899
982
|
)
|
|
900
|
-
logger.info(f"Finalized conversation {conv_id} with topic and summary for session {
|
|
983
|
+
logger.info(f"Finalized conversation {conv_id} with topic and summary for session {channel_id}")
|
|
901
984
|
else:
|
|
902
985
|
# Edge case: conversation history exists but no active ID (shouldn't happen with incremental saves)
|
|
903
|
-
logger.warning(f"Conversation history exists but no active_conversation_id for session {
|
|
986
|
+
logger.warning(f"Conversation history exists but no active_conversation_id for session {channel_id}")
|
|
904
987
|
conv_id = runtime.conversation_store.save_conversation(topic, summary, turns)
|
|
905
|
-
logger.info(f"Created conversation {conv_id} for session {
|
|
988
|
+
logger.info(f"Created conversation {conv_id} for session {channel_id}")
|
|
906
989
|
|
|
907
990
|
# Reserve next conversation ID for the next conversation
|
|
908
991
|
next_id = runtime.conversation_store.reserve_next_conversation_id()
|
|
909
992
|
runtime.active_conversation_id = next_id
|
|
910
993
|
runtime.chat_session.clear_conversation_history()
|
|
911
994
|
|
|
912
|
-
logger.info(f"Ready for new conversation {runtime.active_conversation_id} for session {
|
|
995
|
+
logger.info(f"Ready for new conversation {runtime.active_conversation_id} for session {channel_id}")
|
|
913
996
|
return {"status": "ok"}
|
|
914
997
|
else:
|
|
915
998
|
# No turns to save, just clear history and start fresh
|
|
916
999
|
runtime.chat_session.clear_conversation_history()
|
|
917
|
-
logger.info(f"No turns to save for session {
|
|
1000
|
+
logger.info(f"No turns to save for session {channel_id}, cleared history")
|
|
918
1001
|
return {"status": "ok", "message": "No turns to save"}
|
|
919
1002
|
|
|
920
1003
|
except HTTPException:
|
|
921
1004
|
raise
|
|
922
1005
|
except Exception as e:
|
|
923
|
-
logger.error(f"Error in new_conversation for session {
|
|
1006
|
+
logger.error(f"Error in new_conversation for session {channel_id}: {e}")
|
|
924
1007
|
traceback.print_exc()
|
|
925
1008
|
raise HTTPException(
|
|
926
1009
|
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
|
927
|
-
detail=f"Internal error in new_conversation() for
|
|
1010
|
+
detail=f"Internal error in new_conversation() for channel_id: {channel_id}",
|
|
928
1011
|
) from e
|
|
929
1012
|
|
|
930
1013
|
|
|
@@ -949,23 +1032,23 @@ async def list_conversations(
|
|
|
949
1032
|
|
|
950
1033
|
Requires a valid JWT access token in the Authorization header (Bearer token format).
|
|
951
1034
|
"""
|
|
952
|
-
|
|
1035
|
+
channel_id = session.channel_id
|
|
953
1036
|
try:
|
|
954
|
-
runtime = await session_manager.get_session(
|
|
1037
|
+
runtime = await session_manager.get_session(channel_id)
|
|
955
1038
|
if not runtime:
|
|
956
1039
|
raise HTTPException(
|
|
957
1040
|
status_code=status.HTTP_404_NOT_FOUND,
|
|
958
|
-
detail=f"User session not found: {
|
|
1041
|
+
detail=f"User session not found: {channel_id}"
|
|
959
1042
|
)
|
|
960
1043
|
return runtime.conversation_store.list_conversations(limit)
|
|
961
1044
|
except HTTPException:
|
|
962
1045
|
raise
|
|
963
1046
|
except Exception as e:
|
|
964
|
-
logger.error(f"Error in list_conversations for session {
|
|
1047
|
+
logger.error(f"Error in list_conversations for session {channel_id}: {e}")
|
|
965
1048
|
traceback.print_exc()
|
|
966
1049
|
raise HTTPException(
|
|
967
1050
|
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
|
968
|
-
detail=f"Internal error in list_conversations() for
|
|
1051
|
+
detail=f"Internal error in list_conversations() for channel_id: {channel_id}",
|
|
969
1052
|
) from e
|
|
970
1053
|
|
|
971
1054
|
|
|
@@ -992,20 +1075,20 @@ async def post_feedback(
|
|
|
992
1075
|
|
|
993
1076
|
Requires a valid JWT access token in the Authorization header (Bearer token format).
|
|
994
1077
|
"""
|
|
995
|
-
|
|
1078
|
+
channel_id = session.channel_id
|
|
996
1079
|
try:
|
|
997
|
-
runtime = await session_manager.get_session(
|
|
1080
|
+
runtime = await session_manager.get_session(channel_id)
|
|
998
1081
|
if not runtime:
|
|
999
1082
|
raise HTTPException(
|
|
1000
1083
|
status_code=status.HTTP_404_NOT_FOUND,
|
|
1001
|
-
detail=f"User session not found: {
|
|
1084
|
+
detail=f"User session not found: {channel_id}"
|
|
1002
1085
|
)
|
|
1003
1086
|
|
|
1004
1087
|
# Check if there are any in-memory turns to give feedback on
|
|
1005
1088
|
if not runtime.chat_session.conversation_history.messages:
|
|
1006
1089
|
raise HTTPException(
|
|
1007
1090
|
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
|
1008
|
-
detail=f"No turns available to give feedback on for user: {
|
|
1091
|
+
detail=f"No turns available to give feedback on for user: {channel_id}"
|
|
1009
1092
|
)
|
|
1010
1093
|
|
|
1011
1094
|
# Update feedback on the last turn in the in-memory conversation history
|
|
@@ -1019,17 +1102,17 @@ async def post_feedback(
|
|
|
1019
1102
|
# Incrementally save the updated turns with feedback
|
|
1020
1103
|
save_conversation_incremental(runtime, extract_turns_from_history, logger)
|
|
1021
1104
|
|
|
1022
|
-
logger.info(f"Added feedback to latest turn for session {
|
|
1105
|
+
logger.info(f"Added feedback to latest turn for session {channel_id}")
|
|
1023
1106
|
return {"status": "ok"}
|
|
1024
1107
|
|
|
1025
1108
|
except HTTPException:
|
|
1026
1109
|
raise
|
|
1027
1110
|
except Exception as e:
|
|
1028
|
-
logger.error(f"Error in post_feedback for session {
|
|
1111
|
+
logger.error(f"Error in post_feedback for session {channel_id}: {e}")
|
|
1029
1112
|
traceback.print_exc()
|
|
1030
1113
|
raise HTTPException(
|
|
1031
1114
|
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
|
1032
|
-
detail=f"Internal error in post_feedback() for
|
|
1115
|
+
detail=f"Internal error in post_feedback() for channel_id: {channel_id}",
|
|
1033
1116
|
) from e
|
|
1034
1117
|
|
|
1035
1118
|
|
|
@@ -1052,13 +1135,13 @@ async def activate_conversation(
|
|
|
1052
1135
|
|
|
1053
1136
|
Requires a valid JWT access token in the Authorization header (Bearer token format).
|
|
1054
1137
|
"""
|
|
1055
|
-
|
|
1138
|
+
channel_id = session.channel_id
|
|
1056
1139
|
try:
|
|
1057
|
-
runtime = await session_manager.get_session(
|
|
1140
|
+
runtime = await session_manager.get_session(channel_id)
|
|
1058
1141
|
if not runtime:
|
|
1059
1142
|
raise HTTPException(
|
|
1060
1143
|
status_code=status.HTTP_404_NOT_FOUND,
|
|
1061
|
-
detail=f"User session not found: {
|
|
1144
|
+
detail=f"User session not found: {channel_id}"
|
|
1062
1145
|
)
|
|
1063
1146
|
|
|
1064
1147
|
# Get conversation by ID
|
|
@@ -1074,18 +1157,18 @@ async def activate_conversation(
|
|
|
1074
1157
|
# Restore conversation history to chat_session
|
|
1075
1158
|
restored_history = restore_history_from_turns(conv["turns"])
|
|
1076
1159
|
runtime.chat_session._conversation_history = restored_history
|
|
1077
|
-
logger.info(f"Activated conversation {request.conversation_id} for session {
|
|
1160
|
+
logger.info(f"Activated conversation {request.conversation_id} for session {channel_id}")
|
|
1078
1161
|
|
|
1079
1162
|
return {"status": "ok"}
|
|
1080
1163
|
|
|
1081
1164
|
except HTTPException:
|
|
1082
1165
|
raise
|
|
1083
1166
|
except Exception as e:
|
|
1084
|
-
logger.error(f"Error in activate_conversation for session {
|
|
1167
|
+
logger.error(f"Error in activate_conversation for session {channel_id}: {e}")
|
|
1085
1168
|
traceback.print_exc()
|
|
1086
1169
|
raise HTTPException(
|
|
1087
1170
|
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
|
1088
|
-
detail=f"Internal error in activate_conversation() for
|
|
1171
|
+
detail=f"Internal error in activate_conversation() for channel_id: {channel_id}",
|
|
1089
1172
|
) from e
|
|
1090
1173
|
|
|
1091
1174
|
|
|
@@ -1108,8 +1191,8 @@ async def dump_all_conversations(request: DumpConversationsRequest) -> dict[str,
|
|
|
1108
1191
|
timestamp = int(time.time())
|
|
1109
1192
|
output_file = os.path.join(request.output_folder, f"all_conversations_{timestamp}.jsonl")
|
|
1110
1193
|
|
|
1111
|
-
# Resolve base folder using SPEEDDICT_FOLDERNAME/
|
|
1112
|
-
base_folder =
|
|
1194
|
+
# Resolve base folder using SPEEDDICT_FOLDERNAME/channel_conversations
|
|
1195
|
+
base_folder = get_channelconversations_dir()
|
|
1113
1196
|
|
|
1114
1197
|
all_conversations = []
|
|
1115
1198
|
session_count = 0
|
|
@@ -1118,11 +1201,11 @@ async def dump_all_conversations(request: DumpConversationsRequest) -> dict[str,
|
|
|
1118
1201
|
if os.path.isdir(base_folder):
|
|
1119
1202
|
for filename in os.listdir(base_folder):
|
|
1120
1203
|
if filename.endswith('.rdb'):
|
|
1121
|
-
# Extract
|
|
1122
|
-
|
|
1204
|
+
# Extract channel_id from filename (format: <channel_id>.rdb)
|
|
1205
|
+
channel_id = filename[:-4] # Remove .rdb extension
|
|
1123
1206
|
|
|
1124
1207
|
# Create temporary ConversationStore for this user
|
|
1125
|
-
store = ConversationStore(
|
|
1208
|
+
store = ConversationStore(channel_id, base_folder)
|
|
1126
1209
|
user_convs = store.get_all_conversations_for_dump()
|
|
1127
1210
|
all_conversations.extend(user_convs)
|
|
1128
1211
|
session_count += 1
|
|
@@ -1162,7 +1245,7 @@ async def generate_mcp_token(request: GenerateMCPTokenRequest) -> TokenResponse:
|
|
|
1162
1245
|
and have extended expiration times (default 365 days) since they can't be easily refreshed.
|
|
1163
1246
|
|
|
1164
1247
|
Args:
|
|
1165
|
-
|
|
1248
|
+
channel_id: Identifier for the MCP user/client
|
|
1166
1249
|
expires_days: Token expiration in days (default: 365 days / 1 year)
|
|
1167
1250
|
|
|
1168
1251
|
Returns:
|
|
@@ -1171,10 +1254,10 @@ async def generate_mcp_token(request: GenerateMCPTokenRequest) -> TokenResponse:
|
|
|
1171
1254
|
Note: This endpoint should be restricted to administrators only in production.
|
|
1172
1255
|
"""
|
|
1173
1256
|
try:
|
|
1174
|
-
# Generate long-lived access token
|
|
1175
|
-
access_token = create_access_token(request.user_id, expires_days=request.expires_days)
|
|
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)
|
|
1176
1259
|
|
|
1177
|
-
logger.info(f"Generated MCP token for user_id: {request.user_id}, expires in {request.expires_days} days")
|
|
1260
|
+
logger.info(f"Generated MCP token for channel_id: {request.channel_id}, user_id: {request.user_id}, expires in {request.expires_days} days")
|
|
1178
1261
|
|
|
1179
1262
|
return TokenResponse(
|
|
1180
1263
|
access_token=access_token,
|