fastworkflow 2.15.5__py3-none-any.whl → 2.17.13__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- fastworkflow/_workflows/command_metadata_extraction/_commands/ErrorCorrection/you_misunderstood.py +1 -1
- fastworkflow/_workflows/command_metadata_extraction/_commands/IntentDetection/what_can_i_do.py +16 -2
- fastworkflow/_workflows/command_metadata_extraction/_commands/wildcard.py +27 -570
- fastworkflow/_workflows/command_metadata_extraction/intent_detection.py +360 -0
- fastworkflow/_workflows/command_metadata_extraction/parameter_extraction.py +411 -0
- fastworkflow/chat_session.py +379 -206
- fastworkflow/cli.py +80 -165
- fastworkflow/command_context_model.py +73 -7
- fastworkflow/command_executor.py +14 -5
- fastworkflow/command_metadata_api.py +106 -6
- fastworkflow/examples/fastworkflow.env +2 -1
- fastworkflow/examples/fastworkflow.passwords.env +2 -1
- fastworkflow/examples/retail_workflow/_commands/exchange_delivered_order_items.py +32 -3
- fastworkflow/examples/retail_workflow/_commands/find_user_id_by_email.py +6 -5
- fastworkflow/examples/retail_workflow/_commands/modify_pending_order_items.py +32 -3
- fastworkflow/examples/retail_workflow/_commands/return_delivered_order_items.py +13 -2
- fastworkflow/examples/retail_workflow/_commands/transfer_to_human_agents.py +1 -1
- fastworkflow/intent_clarification_agent.py +131 -0
- fastworkflow/mcp_server.py +3 -3
- fastworkflow/run/__main__.py +33 -40
- fastworkflow/run_fastapi_mcp/README.md +373 -0
- fastworkflow/run_fastapi_mcp/__main__.py +1300 -0
- fastworkflow/run_fastapi_mcp/conversation_store.py +391 -0
- fastworkflow/run_fastapi_mcp/jwt_manager.py +341 -0
- fastworkflow/run_fastapi_mcp/mcp_specific.py +103 -0
- fastworkflow/run_fastapi_mcp/redoc_2_standalone_html.py +40 -0
- fastworkflow/run_fastapi_mcp/utils.py +517 -0
- fastworkflow/train/__main__.py +1 -1
- fastworkflow/utils/chat_adapter.py +99 -0
- fastworkflow/utils/python_utils.py +4 -4
- fastworkflow/utils/react.py +258 -0
- fastworkflow/utils/signatures.py +338 -139
- fastworkflow/workflow.py +1 -5
- fastworkflow/workflow_agent.py +185 -133
- {fastworkflow-2.15.5.dist-info → fastworkflow-2.17.13.dist-info}/METADATA +16 -18
- {fastworkflow-2.15.5.dist-info → fastworkflow-2.17.13.dist-info}/RECORD +40 -30
- fastworkflow/run_agent/__main__.py +0 -294
- fastworkflow/run_agent/agent_module.py +0 -194
- /fastworkflow/{run_agent → run_fastapi_mcp}/__init__.py +0 -0
- {fastworkflow-2.15.5.dist-info → fastworkflow-2.17.13.dist-info}/LICENSE +0 -0
- {fastworkflow-2.15.5.dist-info → fastworkflow-2.17.13.dist-info}/WHEEL +0 -0
- {fastworkflow-2.15.5.dist-info → fastworkflow-2.17.13.dist-info}/entry_points.txt +0 -0
|
@@ -0,0 +1,517 @@
|
|
|
1
|
+
import asyncio
|
|
2
|
+
import os
|
|
3
|
+
import queue
|
|
4
|
+
import time
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
from typing import Any, Optional
|
|
7
|
+
|
|
8
|
+
from fastapi import HTTPException, status, Depends
|
|
9
|
+
from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
|
|
10
|
+
from jose import JWTError
|
|
11
|
+
from pydantic import BaseModel, field_validator
|
|
12
|
+
|
|
13
|
+
import fastworkflow
|
|
14
|
+
from fastworkflow.utils.logging import logger
|
|
15
|
+
|
|
16
|
+
from .conversation_store import ConversationStore, restore_history_from_turns
|
|
17
|
+
from .jwt_manager import verify_token
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
# ============================================================================
|
|
21
|
+
# Data Models (aligned with FastWorkflow canonical types)
|
|
22
|
+
# ============================================================================
|
|
23
|
+
|
|
24
|
+
class InitializationRequest(BaseModel):
|
|
25
|
+
"""Request to initialize a FastWorkflow session for a channel"""
|
|
26
|
+
channel_id: str
|
|
27
|
+
user_id: Optional[str] = None # Required if startup_command or startup_action provided
|
|
28
|
+
stream_format: Optional[str] = None # "ndjson" | "sse" (default ndjson)
|
|
29
|
+
startup_command: Optional[str] = None # Mutually exclusive with startup_action
|
|
30
|
+
startup_action: Optional[dict[str, Any]] = None # Mutually exclusive with startup_command
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class TokenResponse(BaseModel):
|
|
34
|
+
"""JWT token pair returned from initialization or token refresh"""
|
|
35
|
+
access_token: str
|
|
36
|
+
refresh_token: str
|
|
37
|
+
token_type: str = "bearer"
|
|
38
|
+
expires_in: int # Access token expiration in seconds
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
class InitializeResponse(BaseModel):
|
|
42
|
+
"""Response from initialization including tokens and optional startup output"""
|
|
43
|
+
access_token: str
|
|
44
|
+
refresh_token: str
|
|
45
|
+
token_type: str = "bearer"
|
|
46
|
+
expires_in: int # Access token expiration in seconds
|
|
47
|
+
startup_output: Optional[fastworkflow.CommandOutput] = None # Present if startup was executed
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
class SessionData(BaseModel):
|
|
51
|
+
"""Validated session data extracted from JWT token"""
|
|
52
|
+
channel_id: str
|
|
53
|
+
user_id: Optional[str] = None # From JWT uid claim
|
|
54
|
+
token_type: str # "access" or "refresh"
|
|
55
|
+
issued_at: int # Unix timestamp
|
|
56
|
+
expires_at: int # Unix timestamp
|
|
57
|
+
jti: str # JWT ID (unique token identifier)
|
|
58
|
+
http_bearer_token: Optional[str] = None # The actual JWT token string for workflow context access
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
class InvokeRequest(BaseModel):
|
|
62
|
+
"""
|
|
63
|
+
Request to invoke agent or assistant.
|
|
64
|
+
Requires channel_id to be passed in the Authorization header (via JWT token).
|
|
65
|
+
"""
|
|
66
|
+
user_query: str
|
|
67
|
+
timeout_seconds: int = 60
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
class PerformActionRequest(BaseModel):
|
|
71
|
+
"""
|
|
72
|
+
Request to perform a specific action.
|
|
73
|
+
Requires channel_id to be passed in the Authorization header (via JWT token).
|
|
74
|
+
"""
|
|
75
|
+
action: dict[str, Any] # Will be converted to fastworkflow.Action
|
|
76
|
+
timeout_seconds: int = 60
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
class PostFeedbackRequest(BaseModel):
|
|
80
|
+
"""
|
|
81
|
+
Request to post feedback on the latest turn.
|
|
82
|
+
Requires channel_id to be passed in the Authorization header (via JWT token).
|
|
83
|
+
|
|
84
|
+
Note: binary_or_numeric_score accepts numeric values (float).
|
|
85
|
+
Boolean values (True/False) are automatically converted to 1.0/0.0.
|
|
86
|
+
"""
|
|
87
|
+
binary_or_numeric_score: Optional[float] = None
|
|
88
|
+
nl_feedback: Optional[str] = None
|
|
89
|
+
|
|
90
|
+
@field_validator('nl_feedback')
|
|
91
|
+
@classmethod
|
|
92
|
+
def validate_feedback_presence(cls, v, info):
|
|
93
|
+
"""Ensure at least one feedback field is provided"""
|
|
94
|
+
if v is None and info.data.get('binary_or_numeric_score') is None:
|
|
95
|
+
raise ValueError("At least one of binary_or_numeric_score or nl_feedback must be provided")
|
|
96
|
+
return v
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
class ActivateConversationRequest(BaseModel):
|
|
100
|
+
"""
|
|
101
|
+
Request to activate a conversation by ID.
|
|
102
|
+
Requires channel_id to be passed in the Authorization header (via JWT token).
|
|
103
|
+
"""
|
|
104
|
+
conversation_id: int
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
class DumpConversationsRequest(BaseModel):
|
|
108
|
+
"""Admin request to dump all conversations"""
|
|
109
|
+
output_folder: str
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
class GenerateMCPTokenRequest(BaseModel):
|
|
113
|
+
"""Request to generate a long-lived MCP token"""
|
|
114
|
+
channel_id: str
|
|
115
|
+
user_id: Optional[str] = None
|
|
116
|
+
expires_days: int = 365
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
# class CommandOutputWithTraces(BaseModel):
|
|
120
|
+
# """CommandOutput extended with optional traces for HTTP responses"""
|
|
121
|
+
# command_responses: list[dict[str, Any]]
|
|
122
|
+
# workflow_name: str = ""
|
|
123
|
+
# context: str = ""
|
|
124
|
+
# command_name: str = ""
|
|
125
|
+
# command_parameters: str = ""
|
|
126
|
+
# success: bool = True
|
|
127
|
+
# traces: Optional[list[dict[str, Any]]] = None
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
# ============================================================================
|
|
131
|
+
# Helper Functions
|
|
132
|
+
# ============================================================================
|
|
133
|
+
|
|
134
|
+
# Create HTTPBearer security scheme instance
|
|
135
|
+
# This integrates with FastAPI's OpenAPI/Swagger UI to provide the "Authorize" button
|
|
136
|
+
http_bearer = HTTPBearer(
|
|
137
|
+
scheme_name="BearerAuth",
|
|
138
|
+
description="JWT Bearer token obtained from /initialize or /refresh_token endpoint",
|
|
139
|
+
auto_error=True
|
|
140
|
+
)
|
|
141
|
+
|
|
142
|
+
def get_session_from_jwt(
|
|
143
|
+
credentials: HTTPAuthorizationCredentials = Depends(http_bearer)
|
|
144
|
+
) -> SessionData:
|
|
145
|
+
"""
|
|
146
|
+
FastAPI dependency to extract and validate session data from JWT Bearer token.
|
|
147
|
+
|
|
148
|
+
This dependency integrates with FastAPI's security system and Swagger UI:
|
|
149
|
+
- Shows the "Authorize" button in Swagger UI
|
|
150
|
+
- Automatically handles "Bearer " prefix (no need to type it manually)
|
|
151
|
+
- Validates token format and presence
|
|
152
|
+
|
|
153
|
+
Args:
|
|
154
|
+
credentials: HTTPAuthorizationCredentials from the Authorization header.
|
|
155
|
+
FastAPI automatically extracts and validates the Bearer token format.
|
|
156
|
+
|
|
157
|
+
Returns:
|
|
158
|
+
SessionData: Validated session data extracted from the JWT token
|
|
159
|
+
|
|
160
|
+
Raises:
|
|
161
|
+
HTTPException: If the Authorization header is missing, malformed, or contains an invalid/expired token
|
|
162
|
+
|
|
163
|
+
Example:
|
|
164
|
+
Use as a dependency in FastAPI endpoints:
|
|
165
|
+
```python
|
|
166
|
+
@app.post("/endpoint")
|
|
167
|
+
async def endpoint(session: SessionData = Depends(get_session_from_jwt)):
|
|
168
|
+
# Use session.channel_id, session.token_type, etc.
|
|
169
|
+
pass
|
|
170
|
+
```
|
|
171
|
+
|
|
172
|
+
HTTP Request Example:
|
|
173
|
+
```bash
|
|
174
|
+
curl -X POST "http://localhost:8000/endpoint" \\
|
|
175
|
+
-H "Authorization: Bearer eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9..." \\
|
|
176
|
+
-H "Content-Type: application/json" \\
|
|
177
|
+
-d '{"data": "value"}'
|
|
178
|
+
```
|
|
179
|
+
|
|
180
|
+
Swagger UI Usage:
|
|
181
|
+
1. Click the "Authorize" button (lock icon)
|
|
182
|
+
2. Enter ONLY your JWT token (without "Bearer " prefix)
|
|
183
|
+
3. Swagger UI automatically adds the "Bearer " prefix
|
|
184
|
+
"""
|
|
185
|
+
# Extract token from credentials (already validated by HTTPBearer)
|
|
186
|
+
token = credentials.credentials
|
|
187
|
+
|
|
188
|
+
# Verify and decode token
|
|
189
|
+
try:
|
|
190
|
+
payload = verify_token(token, expected_type="access")
|
|
191
|
+
|
|
192
|
+
# Extract session data from payload, including the token for workflow context
|
|
193
|
+
return SessionData(
|
|
194
|
+
channel_id=payload["sub"],
|
|
195
|
+
user_id=payload.get("uid"), # Optional user_id from uid claim
|
|
196
|
+
token_type=payload["type"],
|
|
197
|
+
issued_at=payload["iat"],
|
|
198
|
+
expires_at=payload["exp"],
|
|
199
|
+
jti=payload["jti"],
|
|
200
|
+
http_bearer_token=token # Store the actual token for workflow access
|
|
201
|
+
)
|
|
202
|
+
|
|
203
|
+
except JWTError as e:
|
|
204
|
+
raise HTTPException(
|
|
205
|
+
status_code=status.HTTP_401_UNAUTHORIZED,
|
|
206
|
+
detail=f"Invalid or expired token: {str(e)}",
|
|
207
|
+
headers={"WWW-Authenticate": "Bearer"},
|
|
208
|
+
) from e
|
|
209
|
+
except KeyError as e:
|
|
210
|
+
raise HTTPException(
|
|
211
|
+
status_code=status.HTTP_401_UNAUTHORIZED,
|
|
212
|
+
detail=f"Token missing required claim: {str(e)}",
|
|
213
|
+
headers={"WWW-Authenticate": "Bearer"},
|
|
214
|
+
) from e
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
async def ensure_user_runtime_exists(
|
|
218
|
+
channel_id: str,
|
|
219
|
+
session_manager: 'ChannelSessionManager',
|
|
220
|
+
workflow_path: str,
|
|
221
|
+
context: Optional[dict] = None,
|
|
222
|
+
startup_command: Optional[str] = None,
|
|
223
|
+
startup_action: Optional['fastworkflow.Action'] = None,
|
|
224
|
+
stream_format: str = "ndjson",
|
|
225
|
+
http_bearer_token: Optional[str] = None
|
|
226
|
+
) -> None:
|
|
227
|
+
"""
|
|
228
|
+
Ensure a user runtime exists in the session manager. If not, create it.
|
|
229
|
+
|
|
230
|
+
This function encapsulates the session creation logic from the initialize endpoint,
|
|
231
|
+
allowing it to be reused across different parts of the application without duplicating code.
|
|
232
|
+
|
|
233
|
+
Args:
|
|
234
|
+
channel_id: The user identifier
|
|
235
|
+
session_manager: The ChannelSessionManager instance
|
|
236
|
+
workflow_path: Path to the workflow directory (validated at server startup)
|
|
237
|
+
context: Optional workflow context dictionary
|
|
238
|
+
startup_command: Optional startup command
|
|
239
|
+
startup_action: Optional startup action
|
|
240
|
+
stream_format: Stream format preference ("ndjson" or "sse", default "ndjson")
|
|
241
|
+
http_bearer_token: Optional JWT token to update in workflow context
|
|
242
|
+
|
|
243
|
+
Raises:
|
|
244
|
+
HTTPException: If session creation fails
|
|
245
|
+
"""
|
|
246
|
+
# Check if user already has an active session
|
|
247
|
+
existing_runtime = await session_manager.get_session(channel_id)
|
|
248
|
+
if existing_runtime:
|
|
249
|
+
logger.debug(f"Session for channel_id {channel_id} already exists, skipping creation")
|
|
250
|
+
|
|
251
|
+
# Update the workflow's context with the current token if provided
|
|
252
|
+
if http_bearer_token and existing_runtime.chat_session:
|
|
253
|
+
active_workflow = existing_runtime.chat_session.get_active_workflow()
|
|
254
|
+
if active_workflow and active_workflow.context:
|
|
255
|
+
# Update the workflow's context with the current token
|
|
256
|
+
# Note: We mutate the dictionary in-place (no setter call), which means:
|
|
257
|
+
# 1. The change is immediate and visible to workflow code
|
|
258
|
+
# 2. The workflow is NOT marked dirty (won't persist to disk)
|
|
259
|
+
# 3. This is intentional for JWT tokens - we don't want to persist sensitive tokens
|
|
260
|
+
active_workflow.context['http_bearer_token'] = http_bearer_token
|
|
261
|
+
logger.debug(f"Updated http_bearer_token in workflow context for channel_id {channel_id}")
|
|
262
|
+
|
|
263
|
+
return
|
|
264
|
+
|
|
265
|
+
# Prepare workflow context, ensuring http_bearer_token is available
|
|
266
|
+
if http_bearer_token:
|
|
267
|
+
if context:
|
|
268
|
+
# Add or replace http_bearer_token in the context
|
|
269
|
+
context['http_bearer_token'] = http_bearer_token
|
|
270
|
+
else:
|
|
271
|
+
# Initialize context with http_bearer_token
|
|
272
|
+
context = {'http_bearer_token': http_bearer_token}
|
|
273
|
+
|
|
274
|
+
logger.info(f"Creating new session for channel_id: {channel_id}")
|
|
275
|
+
|
|
276
|
+
# Resolve conversation store base folder from SPEEDDICT_FOLDERNAME/channel_conversations
|
|
277
|
+
conv_base_folder = get_channelconversations_dir()
|
|
278
|
+
|
|
279
|
+
# Create conversation store for this user
|
|
280
|
+
conversation_store = ConversationStore(channel_id, conv_base_folder)
|
|
281
|
+
|
|
282
|
+
# Create ChatSession in agent mode (forced)
|
|
283
|
+
chat_session = fastworkflow.ChatSession(run_as_agent=True)
|
|
284
|
+
|
|
285
|
+
# Restore last conversation if it exists; else start new
|
|
286
|
+
conv_id_to_restore = None
|
|
287
|
+
if conv_id_to_restore := conversation_store.get_last_conversation_id():
|
|
288
|
+
conversation = conversation_store.get_conversation(conv_id_to_restore)
|
|
289
|
+
if not conversation:
|
|
290
|
+
# this means a new conversation was started but not saved
|
|
291
|
+
conv_id_to_restore = conv_id_to_restore-1
|
|
292
|
+
conversation = conversation_store.get_conversation(conv_id_to_restore)
|
|
293
|
+
|
|
294
|
+
if conversation:
|
|
295
|
+
# Restore the conversation history from saved turns
|
|
296
|
+
restored_history = restore_history_from_turns(conversation["turns"])
|
|
297
|
+
chat_session._conversation_history = restored_history
|
|
298
|
+
logger.info(f"Restored conversation {conv_id_to_restore} for user {channel_id}")
|
|
299
|
+
else:
|
|
300
|
+
logger.info(f"No conversations available for user {channel_id}, starting new")
|
|
301
|
+
conv_id_to_restore = None
|
|
302
|
+
|
|
303
|
+
# Start the workflow
|
|
304
|
+
chat_session.start_workflow(
|
|
305
|
+
workflow_folderpath=workflow_path,
|
|
306
|
+
workflow_context=context,
|
|
307
|
+
startup_command=startup_command,
|
|
308
|
+
startup_action=startup_action,
|
|
309
|
+
keep_alive=True
|
|
310
|
+
)
|
|
311
|
+
|
|
312
|
+
# Create and store user runtime
|
|
313
|
+
await session_manager.create_session(
|
|
314
|
+
channel_id=channel_id,
|
|
315
|
+
chat_session=chat_session,
|
|
316
|
+
conversation_store=conversation_store,
|
|
317
|
+
active_conversation_id=conv_id_to_restore,
|
|
318
|
+
stream_format=stream_format
|
|
319
|
+
)
|
|
320
|
+
|
|
321
|
+
logger.info(f"Successfully created session for channel_id: {channel_id}")
|
|
322
|
+
|
|
323
|
+
# Wait for workflow to be ready (background thread sets status to RUNNING)
|
|
324
|
+
import asyncio
|
|
325
|
+
import time
|
|
326
|
+
max_wait = 5 # seconds
|
|
327
|
+
wait_start = time.time()
|
|
328
|
+
from fastworkflow.chat_session import SessionStatus
|
|
329
|
+
while chat_session._status != SessionStatus.RUNNING and time.time() - wait_start < max_wait:
|
|
330
|
+
await asyncio.sleep(0.1)
|
|
331
|
+
|
|
332
|
+
if chat_session._status != SessionStatus.RUNNING:
|
|
333
|
+
logger.warning(f"Workflow not fully started after {max_wait}s, status={chat_session._status}")
|
|
334
|
+
|
|
335
|
+
|
|
336
|
+
def get_channelconversations_dir() -> str:
|
|
337
|
+
"""
|
|
338
|
+
Return SPEEDDICT_FOLDERNAME/channel_conversations, creating the directory if missing.
|
|
339
|
+
fastworkflow is injected to avoid circular imports and to access get_env_var.
|
|
340
|
+
"""
|
|
341
|
+
speedict_foldername = fastworkflow.get_env_var("SPEEDDICT_FOLDERNAME")
|
|
342
|
+
user_conversations_dir = os.path.join(speedict_foldername, "channel_conversations")
|
|
343
|
+
os.makedirs(user_conversations_dir, exist_ok=True)
|
|
344
|
+
return user_conversations_dir
|
|
345
|
+
|
|
346
|
+
|
|
347
|
+
async def wait_for_command_output(
|
|
348
|
+
runtime: 'ChannelRuntime',
|
|
349
|
+
timeout_seconds: int
|
|
350
|
+
) -> 'fastworkflow.CommandOutput':
|
|
351
|
+
"""Wait for command output from the queue with timeout"""
|
|
352
|
+
start_time = time.time()
|
|
353
|
+
|
|
354
|
+
while time.time() - start_time < timeout_seconds:
|
|
355
|
+
try:
|
|
356
|
+
return runtime.chat_session.command_output_queue.get(timeout=0.5)
|
|
357
|
+
except queue.Empty:
|
|
358
|
+
await asyncio.sleep(0.1)
|
|
359
|
+
continue
|
|
360
|
+
|
|
361
|
+
raise HTTPException(
|
|
362
|
+
status_code=status.HTTP_504_GATEWAY_TIMEOUT,
|
|
363
|
+
detail=f"Command execution timed out after {timeout_seconds} seconds"
|
|
364
|
+
)
|
|
365
|
+
|
|
366
|
+
|
|
367
|
+
def collect_trace_events(runtime: 'ChannelRuntime', user_id: Optional[str] = None) -> list[dict[str, Any]]:
|
|
368
|
+
"""
|
|
369
|
+
Drain and collect all trace events from the queue.
|
|
370
|
+
|
|
371
|
+
Args:
|
|
372
|
+
runtime: ChannelRuntime containing the trace queue
|
|
373
|
+
user_id: Optional user_id to include in traces
|
|
374
|
+
|
|
375
|
+
Returns:
|
|
376
|
+
List of trace event dictionaries with optional user_id
|
|
377
|
+
"""
|
|
378
|
+
traces = []
|
|
379
|
+
|
|
380
|
+
while True:
|
|
381
|
+
try:
|
|
382
|
+
evt = runtime.chat_session.command_trace_queue.get_nowait()
|
|
383
|
+
trace = {
|
|
384
|
+
"direction": evt.direction.value if hasattr(evt.direction, 'value') else str(evt.direction),
|
|
385
|
+
"raw_command": evt.raw_command,
|
|
386
|
+
"command_name": evt.command_name,
|
|
387
|
+
"parameters": evt.parameters,
|
|
388
|
+
"response_text": evt.response_text,
|
|
389
|
+
"success": evt.success,
|
|
390
|
+
"timestamp_ms": evt.timestamp_ms
|
|
391
|
+
}
|
|
392
|
+
if user_id is not None:
|
|
393
|
+
trace["user_id"] = user_id
|
|
394
|
+
traces.append(trace)
|
|
395
|
+
except queue.Empty:
|
|
396
|
+
break
|
|
397
|
+
|
|
398
|
+
return traces
|
|
399
|
+
|
|
400
|
+
|
|
401
|
+
async def collect_trace_events_async(
|
|
402
|
+
trace_queue: queue.Queue,
|
|
403
|
+
user_id: Optional[str] = None
|
|
404
|
+
) -> list[dict[str, Any]]:
|
|
405
|
+
"""
|
|
406
|
+
Async version: Drain and collect all trace events from a trace queue.
|
|
407
|
+
|
|
408
|
+
Args:
|
|
409
|
+
trace_queue: The trace queue to drain
|
|
410
|
+
user_id: Optional user_id to include in traces
|
|
411
|
+
|
|
412
|
+
Returns:
|
|
413
|
+
List of trace event dictionaries with optional user_id
|
|
414
|
+
"""
|
|
415
|
+
traces = []
|
|
416
|
+
|
|
417
|
+
while True:
|
|
418
|
+
try:
|
|
419
|
+
evt = trace_queue.get_nowait()
|
|
420
|
+
trace = {
|
|
421
|
+
"direction": evt.direction.value if hasattr(evt.direction, 'value') else str(evt.direction),
|
|
422
|
+
"raw_command": evt.raw_command,
|
|
423
|
+
"command_name": evt.command_name,
|
|
424
|
+
"parameters": evt.parameters,
|
|
425
|
+
"response_text": evt.response_text,
|
|
426
|
+
"success": evt.success,
|
|
427
|
+
"timestamp_ms": evt.timestamp_ms
|
|
428
|
+
}
|
|
429
|
+
if user_id is not None:
|
|
430
|
+
trace["user_id"] = user_id
|
|
431
|
+
traces.append(trace)
|
|
432
|
+
except queue.Empty:
|
|
433
|
+
break
|
|
434
|
+
|
|
435
|
+
return traces
|
|
436
|
+
|
|
437
|
+
|
|
438
|
+
# ============================================================================
|
|
439
|
+
# Session Management
|
|
440
|
+
# ============================================================================
|
|
441
|
+
|
|
442
|
+
@dataclass
|
|
443
|
+
class ChannelRuntime:
|
|
444
|
+
"""Per-channel runtime state"""
|
|
445
|
+
channel_id: str
|
|
446
|
+
active_conversation_id: int
|
|
447
|
+
chat_session: 'fastworkflow.ChatSession'
|
|
448
|
+
lock: asyncio.Lock
|
|
449
|
+
conversation_store: 'ConversationStore'
|
|
450
|
+
stream_format: str = "ndjson" # "ndjson" | "sse"
|
|
451
|
+
|
|
452
|
+
|
|
453
|
+
class ChannelSessionManager:
|
|
454
|
+
"""Process-wide manager for channel sessions"""
|
|
455
|
+
|
|
456
|
+
def __init__(self):
|
|
457
|
+
self._sessions: dict[str, ChannelRuntime] = {}
|
|
458
|
+
self._lock = asyncio.Lock()
|
|
459
|
+
|
|
460
|
+
async def get_session(self, channel_id: str) -> Optional[ChannelRuntime]:
|
|
461
|
+
"""Get a session by channel_id"""
|
|
462
|
+
async with self._lock:
|
|
463
|
+
return self._sessions.get(channel_id)
|
|
464
|
+
|
|
465
|
+
async def create_session(
|
|
466
|
+
self,
|
|
467
|
+
channel_id: str,
|
|
468
|
+
chat_session: 'fastworkflow.ChatSession',
|
|
469
|
+
conversation_store: 'ConversationStore',
|
|
470
|
+
active_conversation_id: Optional[int] = None,
|
|
471
|
+
stream_format: str = "ndjson"
|
|
472
|
+
) -> ChannelRuntime:
|
|
473
|
+
"""Create or update a session"""
|
|
474
|
+
async with self._lock:
|
|
475
|
+
runtime = ChannelRuntime(
|
|
476
|
+
channel_id=channel_id,
|
|
477
|
+
active_conversation_id=active_conversation_id or 0,
|
|
478
|
+
chat_session=chat_session,
|
|
479
|
+
lock=asyncio.Lock(),
|
|
480
|
+
conversation_store=conversation_store,
|
|
481
|
+
stream_format=stream_format
|
|
482
|
+
)
|
|
483
|
+
self._sessions[channel_id] = runtime
|
|
484
|
+
return runtime
|
|
485
|
+
|
|
486
|
+
async def remove_session(self, channel_id: str) -> None:
|
|
487
|
+
"""Remove a session"""
|
|
488
|
+
async with self._lock:
|
|
489
|
+
if channel_id in self._sessions:
|
|
490
|
+
del self._sessions[channel_id]
|
|
491
|
+
|
|
492
|
+
|
|
493
|
+
# ============================================================================
|
|
494
|
+
# Helper Functions
|
|
495
|
+
# ============================================================================
|
|
496
|
+
|
|
497
|
+
def save_conversation_incremental(runtime: ChannelRuntime, extract_turns_func, logger) -> None:
|
|
498
|
+
"""
|
|
499
|
+
Save conversation turns incrementally after each turn (without generating topic/summary).
|
|
500
|
+
This provides crash protection - all turns except the last will be preserved.
|
|
501
|
+
"""
|
|
502
|
+
# Extract turns from conversation history
|
|
503
|
+
if turns := extract_turns_func(runtime.chat_session.conversation_history):
|
|
504
|
+
# Initialize conversation ID for first conversation if needed
|
|
505
|
+
if runtime.active_conversation_id == 0:
|
|
506
|
+
# This is the first conversation for this session
|
|
507
|
+
# Reserve ID 1 and use it
|
|
508
|
+
runtime.active_conversation_id = runtime.conversation_store.reserve_next_conversation_id()
|
|
509
|
+
logger.debug(f"Initialized first conversation with ID {runtime.active_conversation_id} for user {runtime.channel_id}")
|
|
510
|
+
|
|
511
|
+
# Save turns using the active conversation ID
|
|
512
|
+
runtime.conversation_store.save_conversation_turns(
|
|
513
|
+
runtime.active_conversation_id, turns
|
|
514
|
+
)
|
|
515
|
+
logger.debug(f"Incrementally saved {len(turns)} turn(s) to conversation {runtime.active_conversation_id}")
|
|
516
|
+
|
|
517
|
+
|
fastworkflow/train/__main__.py
CHANGED
|
@@ -114,7 +114,7 @@ def _get_commands_with_parameters(json_path):
|
|
|
114
114
|
command_directory = json.load(f)
|
|
115
115
|
|
|
116
116
|
# Extract the command metadata
|
|
117
|
-
commands_metadata = command_directory.get("
|
|
117
|
+
commands_metadata = command_directory.get("map_command_2_metadata", {})
|
|
118
118
|
|
|
119
119
|
# Initialize result dictionary
|
|
120
120
|
commands_with_parameters = {}
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
"""
|
|
2
|
+
ChatAdapter wrapper for injecting context-specific available commands into system messages.
|
|
3
|
+
|
|
4
|
+
Design Overview:
|
|
5
|
+
---------------
|
|
6
|
+
This module implements a ChatAdapter wrapper that dynamically injects workflow command information
|
|
7
|
+
into the system message at runtime, avoiding the need to rebuild ReAct agent modules per context.
|
|
8
|
+
|
|
9
|
+
Key Benefits:
|
|
10
|
+
- Single shared agent: No per-context module caching required
|
|
11
|
+
- Dynamic updates: Commands refresh per call based on current workflow context
|
|
12
|
+
- Token efficiency: Commands appear in system (not repeated in trajectory/history)
|
|
13
|
+
- Zero rebuild cost: Signature and modules remain stable across context changes
|
|
14
|
+
|
|
15
|
+
Usage:
|
|
16
|
+
------
|
|
17
|
+
The adapter is used specifically for workflow agent calls via dspy.context():
|
|
18
|
+
|
|
19
|
+
from fastworkflow.utils.chat_adapter import CommandsSystemPreludeAdapter
|
|
20
|
+
|
|
21
|
+
agent_adapter = CommandsSystemPreludeAdapter()
|
|
22
|
+
available_commands = _what_can_i_do(chat_session)
|
|
23
|
+
|
|
24
|
+
with dspy.context(lm=lm, adapter=agent_adapter):
|
|
25
|
+
agent_result = agent(
|
|
26
|
+
user_query="...",
|
|
27
|
+
available_commands=available_commands
|
|
28
|
+
)
|
|
29
|
+
|
|
30
|
+
The adapter intercepts the format call and prepends commands to the system message,
|
|
31
|
+
keeping them out of the trajectory to prevent token bloat across iterations.
|
|
32
|
+
This scoped approach ensures the adapter only affects workflow agent calls, not other
|
|
33
|
+
DSPy operations in the system.
|
|
34
|
+
"""
|
|
35
|
+
import dspy
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
class CommandsSystemPreludeAdapter(dspy.ChatAdapter):
|
|
39
|
+
"""
|
|
40
|
+
Wraps a base DSPy ChatAdapter to inject available commands into the system message.
|
|
41
|
+
|
|
42
|
+
This adapter intercepts the render process and prepends a "Available commands" section
|
|
43
|
+
to the system message when `available_commands` is present in inputs. This ensures
|
|
44
|
+
commands are visible to the model at each step without being added to the trajectory
|
|
45
|
+
or conversation history.
|
|
46
|
+
|
|
47
|
+
Args:
|
|
48
|
+
base: The underlying ChatAdapter to wrap. Defaults to dspy.ChatAdapter() if None.
|
|
49
|
+
title: The header text for the commands section. Defaults to "Available commands".
|
|
50
|
+
|
|
51
|
+
Example:
|
|
52
|
+
>>> import dspy
|
|
53
|
+
>>> from fastworkflow.utils.chat_adapter import CommandsSystemPreludeAdapter
|
|
54
|
+
>>> dspy.settings.adapter = CommandsSystemPreludeAdapter()
|
|
55
|
+
"""
|
|
56
|
+
|
|
57
|
+
def __init__(self, base: dspy.ChatAdapter | None = None, title: str = "Available execute_workflow_query tool commands"):
|
|
58
|
+
super().__init__()
|
|
59
|
+
self.base = base or dspy.ChatAdapter()
|
|
60
|
+
self.title = title
|
|
61
|
+
|
|
62
|
+
def format(self, signature, demos, inputs):
|
|
63
|
+
"""
|
|
64
|
+
Format the inputs for the model, injecting available_commands into system message.
|
|
65
|
+
|
|
66
|
+
This method wraps the base adapter's format method and modifies the result
|
|
67
|
+
to include available commands in the system message if present in inputs.
|
|
68
|
+
|
|
69
|
+
Args:
|
|
70
|
+
signature: The DSPy signature defining the task
|
|
71
|
+
demos: List of demonstration examples
|
|
72
|
+
inputs: Dictionary of input values, may include 'available_commands'
|
|
73
|
+
|
|
74
|
+
Returns:
|
|
75
|
+
Formatted messages with commands injected into system message
|
|
76
|
+
"""
|
|
77
|
+
# Call the base adapter's format method
|
|
78
|
+
formatted = self.base.format(signature, demos, inputs)
|
|
79
|
+
|
|
80
|
+
# Check if available_commands is in inputs
|
|
81
|
+
cmds = inputs.get("available_commands")
|
|
82
|
+
if not cmds:
|
|
83
|
+
return formatted
|
|
84
|
+
|
|
85
|
+
# Inject commands into the system message
|
|
86
|
+
prelude = f"{self.title}:\n{cmds}".strip()
|
|
87
|
+
|
|
88
|
+
# Formatted output is a list of messages, first may be system
|
|
89
|
+
# Find and modify the system message, or prepend one
|
|
90
|
+
if formatted and formatted[0].get("role") == "system":
|
|
91
|
+
# Prepend to existing system message
|
|
92
|
+
existing_content = formatted[0].get("content", "")
|
|
93
|
+
formatted[0]["content"] = f"{prelude}\n\n{existing_content}".strip()
|
|
94
|
+
else:
|
|
95
|
+
# No system message exists, prepend one
|
|
96
|
+
formatted.insert(0, {"role": "system", "content": prelude})
|
|
97
|
+
|
|
98
|
+
return formatted
|
|
99
|
+
|
|
@@ -7,6 +7,8 @@ from functools import lru_cache
|
|
|
7
7
|
from pathlib import Path
|
|
8
8
|
from typing import Any, Optional
|
|
9
9
|
|
|
10
|
+
from fastworkflow.utils.logging import logger
|
|
11
|
+
|
|
10
12
|
# Normalize arguments so logically identical calls share the same cache key
|
|
11
13
|
@lru_cache(maxsize=128)
|
|
12
14
|
def get_module(module_path: str, search_root: Optional[str] = None) -> Any:
|
|
@@ -90,12 +92,10 @@ def get_module(module_path: str, search_root: Optional[str] = None) -> Any:
|
|
|
90
92
|
spec.loader.exec_module(module)
|
|
91
93
|
return module
|
|
92
94
|
|
|
93
|
-
except
|
|
94
|
-
|
|
95
|
+
except Exception as e:
|
|
96
|
+
logger.critical(f"Could not import module from path: {module_path}. Error: {e}")
|
|
95
97
|
raise ImportError(
|
|
96
98
|
f"Could not import module from path: {module_path}. Error: {e}") from e
|
|
97
|
-
except Exception:
|
|
98
|
-
return None
|
|
99
99
|
|
|
100
100
|
def get_module_import_path(file_path: str, source_dir: str) -> str:
|
|
101
101
|
"""
|