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

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