fastworkflow 2.13.5__py3-none-any.whl → 2.14.1__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (43) hide show
  1. fastworkflow/_workflows/command_metadata_extraction/_commands/ErrorCorrection/you_misunderstood.py +1 -1
  2. fastworkflow/_workflows/command_metadata_extraction/_commands/IntentDetection/go_up.py +1 -1
  3. fastworkflow/_workflows/command_metadata_extraction/_commands/IntentDetection/reset_context.py +1 -1
  4. fastworkflow/_workflows/command_metadata_extraction/_commands/IntentDetection/what_can_i_do.py +98 -166
  5. fastworkflow/_workflows/command_metadata_extraction/_commands/wildcard.py +7 -3
  6. fastworkflow/build/genai_postprocessor.py +143 -149
  7. fastworkflow/chat_session.py +42 -11
  8. fastworkflow/command_metadata_api.py +794 -0
  9. fastworkflow/command_routing.py +4 -1
  10. fastworkflow/examples/fastworkflow.env +1 -1
  11. fastworkflow/examples/fastworkflow.passwords.env +1 -0
  12. fastworkflow/examples/hello_world/_commands/add_two_numbers.py +1 -0
  13. fastworkflow/examples/retail_workflow/_commands/calculate.py +67 -0
  14. fastworkflow/examples/retail_workflow/_commands/cancel_pending_order.py +4 -1
  15. fastworkflow/examples/retail_workflow/_commands/exchange_delivered_order_items.py +13 -1
  16. fastworkflow/examples/retail_workflow/_commands/find_user_id_by_email.py +6 -1
  17. fastworkflow/examples/retail_workflow/_commands/find_user_id_by_name_zip.py +6 -1
  18. fastworkflow/examples/retail_workflow/_commands/get_order_details.py +22 -10
  19. fastworkflow/examples/retail_workflow/_commands/get_product_details.py +12 -4
  20. fastworkflow/examples/retail_workflow/_commands/get_user_details.py +21 -5
  21. fastworkflow/examples/retail_workflow/_commands/list_all_product_types.py +4 -1
  22. fastworkflow/examples/retail_workflow/_commands/modify_pending_order_address.py +3 -0
  23. fastworkflow/examples/retail_workflow/_commands/modify_pending_order_items.py +12 -0
  24. fastworkflow/examples/retail_workflow/_commands/modify_pending_order_payment.py +7 -1
  25. fastworkflow/examples/retail_workflow/_commands/modify_user_address.py +3 -0
  26. fastworkflow/examples/retail_workflow/_commands/return_delivered_order_items.py +10 -1
  27. fastworkflow/examples/retail_workflow/_commands/transfer_to_human_agents.py +1 -1
  28. fastworkflow/examples/retail_workflow/tools/calculate.py +1 -1
  29. fastworkflow/mcp_server.py +52 -44
  30. fastworkflow/run/__main__.py +9 -5
  31. fastworkflow/run_agent/__main__.py +8 -8
  32. fastworkflow/run_agent/agent_module.py +6 -16
  33. fastworkflow/utils/command_dependency_graph.py +130 -143
  34. fastworkflow/utils/dspy_utils.py +11 -0
  35. fastworkflow/utils/signatures.py +7 -0
  36. fastworkflow/workflow_agent.py +186 -0
  37. {fastworkflow-2.13.5.dist-info → fastworkflow-2.14.1.dist-info}/METADATA +12 -3
  38. {fastworkflow-2.13.5.dist-info → fastworkflow-2.14.1.dist-info}/RECORD +41 -40
  39. fastworkflow/agent_integration.py +0 -239
  40. fastworkflow/examples/retail_workflow/_commands/parameter_dependency_graph.json +0 -36
  41. {fastworkflow-2.13.5.dist-info → fastworkflow-2.14.1.dist-info}/LICENSE +0 -0
  42. {fastworkflow-2.13.5.dist-info → fastworkflow-2.14.1.dist-info}/WHEEL +0 -0
  43. {fastworkflow-2.13.5.dist-info → fastworkflow-2.14.1.dist-info}/entry_points.txt +0 -0
@@ -0,0 +1,794 @@
1
+ """
2
+ This module provides common utilities for FastWorkflow,
3
+ including a centralized API for extracting command metadata.
4
+ """
5
+ from __future__ import annotations
6
+
7
+ import contextlib
8
+ from typing import Any, Dict, List
9
+ import inspect
10
+ from pathlib import Path
11
+ import json
12
+
13
+ import fastworkflow
14
+ from fastworkflow.command_routing import RoutingDefinition
15
+ from fastworkflow.command_directory import CommandDirectory
16
+ from fastworkflow.utils import python_utils
17
+
18
+ def _is_pydantic_undefined(value: Any) -> bool:
19
+ """Return True if value appears to be Pydantic's 'undefined' sentinel.
20
+
21
+ We avoid importing Pydantic internals; instead detect by type name to keep
22
+ compatibility across Pydantic versions.
23
+ """
24
+ try:
25
+ type_name = type(value).__name__
26
+ return type_name in {"PydanticUndefined", "PydanticUndefinedType"}
27
+ except Exception:
28
+ return False
29
+
30
+ class CommandMetadataAPI:
31
+ """
32
+ Provides a centralized API for extracting command metadata.
33
+ """
34
+
35
+ @staticmethod
36
+ def get_enhanced_command_info(
37
+ subject_workflow_path: str,
38
+ cme_workflow_path: str,
39
+ active_context_name: str,
40
+ ) -> Dict[str, Any]:
41
+ """
42
+ Get enhanced command information for a given workflow and context.
43
+
44
+ Args:
45
+ subject_workflow_path: Path to the subject workflow
46
+ cme_workflow_path: Path to the Command Metadata Extraction workflow
47
+ active_context_name: Name of the active command context
48
+
49
+ Returns:
50
+ Structured dictionary with context and command details
51
+ """
52
+ subject_crd = fastworkflow.RoutingRegistry.get_definition(subject_workflow_path)
53
+ cme_crd = fastworkflow.RoutingRegistry.get_definition(cme_workflow_path)
54
+
55
+ cme_command_names = cme_crd.get_command_names('IntentDetection')
56
+ subject_command_names = subject_crd.get_command_names(active_context_name)
57
+
58
+ candidate_commands = set(cme_command_names) | set(subject_command_names)
59
+
60
+ commands = []
61
+ for fq_cmd in candidate_commands:
62
+ if fq_cmd == "wildcard":
63
+ continue
64
+
65
+ utterance_meta = (
66
+ subject_crd.command_directory.get_utterance_metadata(fq_cmd) or
67
+ cme_crd.command_directory.get_utterance_metadata(fq_cmd)
68
+ )
69
+
70
+ if not utterance_meta:
71
+ continue
72
+
73
+ cmd_name = fq_cmd.split("/")[-1]
74
+ signature_info = CommandMetadataAPI._extract_signature_info(fq_cmd, subject_crd, cme_crd)
75
+
76
+ commands.append({
77
+ "qualified_name": fq_cmd,
78
+ "name": cmd_name,
79
+ **signature_info
80
+ })
81
+
82
+ # This part is simplified as context info is now built outside
83
+ return {"commands": sorted(commands, key=lambda x: x["name"])}
84
+
85
+ @staticmethod
86
+ def _extract_signature_info(fq_cmd: str, subject_crd: RoutingDefinition, cme_crd: RoutingDefinition) -> Dict[str, Any]:
87
+ """
88
+ Extracts signature information for a command.
89
+ """
90
+ signature_info = {}
91
+ with contextlib.suppress(Exception):
92
+ signature_class = None
93
+ try:
94
+ signature_class = subject_crd.get_command_class(
95
+ fq_cmd,
96
+ fastworkflow.ModuleType.INPUT_FOR_PARAM_EXTRACTION_CLASS,
97
+ )
98
+ except Exception:
99
+ with contextlib.suppress(Exception):
100
+ signature_class = cme_crd.get_command_class(
101
+ fq_cmd,
102
+ fastworkflow.ModuleType.INPUT_FOR_PARAM_EXTRACTION_CLASS,
103
+ )
104
+
105
+ if signature_class:
106
+ sig_class = signature_class
107
+ # Attach docstring from the Signature class itself
108
+ with contextlib.suppress(Exception):
109
+ if doc := inspect.getdoc(sig_class) or getattr(
110
+ sig_class, "__doc__", None
111
+ ):
112
+ signature_info["doc_string"] = doc
113
+
114
+ if hasattr(sig_class, 'Input') and hasattr(sig_class.Input, 'model_fields'):
115
+ input_class = sig_class.Input
116
+ inputs_list: List[Dict[str, Any]] = []
117
+ for field_name, field_info in input_class.model_fields.items():
118
+ pattern_value = CommandMetadataAPI._get_field_pattern(field_info)
119
+ # Extract optional 'available_from' from json_schema_extra
120
+ available_from_value = None
121
+ with contextlib.suppress(Exception):
122
+ extra = getattr(field_info, 'json_schema_extra', None)
123
+ if isinstance(extra, dict) and extra.get('available_from'):
124
+ available_from_value = str(extra.get('available_from'))
125
+ inputs_list.append(
126
+ {
127
+ "name": field_name,
128
+ "type": str(getattr(field_info, 'annotation', '')),
129
+ "description": getattr(field_info, 'description', "") or "",
130
+ "examples": list(getattr(field_info, 'examples', []) or []),
131
+ "default": (None if _is_pydantic_undefined(getattr(field_info, 'default', None)) else getattr(field_info, 'default', None)),
132
+ "pattern": pattern_value,
133
+ "available_from": available_from_value,
134
+ }
135
+ )
136
+ signature_info["inputs"] = inputs_list
137
+
138
+ # Include output model fields if defined
139
+ if hasattr(sig_class, 'Output') and hasattr(sig_class.Output, 'model_fields'):
140
+ output_class = sig_class.Output
141
+ outputs_list: List[Dict[str, Any]] = []
142
+ outputs_list.extend(
143
+ {
144
+ "name": field_name,
145
+ "type": str(getattr(field_info, 'annotation', '')),
146
+ "description": getattr(field_info, 'description', "")
147
+ or "",
148
+ "examples": list(
149
+ getattr(field_info, 'examples', []) or []
150
+ ),
151
+ }
152
+ for field_name, field_info in output_class.model_fields.items()
153
+ )
154
+ signature_info["outputs"] = outputs_list
155
+
156
+ if hasattr(sig_class, 'plain_utterances'):
157
+ signature_info["plain_utterances"] = list(getattr(sig_class, 'plain_utterances') or [])
158
+ return signature_info
159
+
160
+ @staticmethod
161
+ def _get_field_pattern(field_info: Any) -> Any:
162
+ """
163
+ Best-effort extraction of a pattern/regex constraint from a Pydantic field across versions.
164
+ Returns a string pattern if available, otherwise None.
165
+ """
166
+ # Direct attributes commonly present across versions
167
+ with contextlib.suppress(Exception):
168
+ # pydantic v1 sometimes uses 'regex'
169
+ regex_attr = getattr(field_info, 'regex', None)
170
+ if regex_attr is not None:
171
+ try:
172
+ return regex_attr.pattern if hasattr(regex_attr, 'pattern') else str(regex_attr)
173
+ except Exception:
174
+ return str(regex_attr)
175
+
176
+ with contextlib.suppress(Exception):
177
+ if pattern_attr := getattr(field_info, 'pattern', None):
178
+ return str(pattern_attr)
179
+
180
+ with contextlib.suppress(Exception):
181
+ extra = getattr(field_info, 'json_schema_extra', None)
182
+ if isinstance(extra, dict) and 'pattern' in extra and extra['pattern']:
183
+ return str(extra['pattern'])
184
+
185
+ # Inspect metadata/annotation for Annotated[StringConstraints]
186
+ with contextlib.suppress(Exception):
187
+ annotation = getattr(field_info, 'annotation', None)
188
+ # Attempt to unwrap typing.Annotated
189
+ with contextlib.suppress(Exception):
190
+ from typing import get_origin, get_args, Annotated # type: ignore
191
+ if get_origin(annotation) is Annotated:
192
+ for meta in get_args(annotation)[1:]:
193
+ # StringConstraints in pydantic v2
194
+ if hasattr(meta, 'pattern') and getattr(meta, 'pattern'):
195
+ return str(getattr(meta, 'pattern'))
196
+ if hasattr(meta, 'regex') and getattr(meta, 'regex'):
197
+ rx = getattr(meta, 'regex')
198
+ return rx.pattern if hasattr(rx, 'pattern') else str(rx)
199
+ # Some versions store constraints in 'metadata' tuple
200
+ with contextlib.suppress(Exception):
201
+ metadata = getattr(field_info, 'metadata', None)
202
+ if metadata and isinstance(metadata, (list, tuple)):
203
+ for item in metadata:
204
+ if hasattr(item, 'pattern') and getattr(item, 'pattern'):
205
+ return str(getattr(item, 'pattern'))
206
+ if hasattr(item, 'regex') and getattr(item, 'regex'):
207
+ rx = getattr(item, 'regex')
208
+ return rx.pattern if hasattr(rx, 'pattern') else str(rx)
209
+ return None
210
+
211
+ # ------------------------------------------------------------------
212
+ # Display helpers
213
+ # ------------------------------------------------------------------
214
+ @staticmethod
215
+ def _simplify_type_str(type_str: str) -> str:
216
+ """Simplify verbose type strings like "<class 'float'>" to "float"."""
217
+ if type_str.startswith("<class '") and type_str.endswith("'>"):
218
+ return type_str[len("<class '"):-2]
219
+ return type_str
220
+
221
+ @staticmethod
222
+ def _prune_empty(value: Any, remove_keys: set[str] | None = None) -> Any:
223
+ """
224
+ Recursively remove keys with empty values (None, '', [], {}) from dicts/lists.
225
+ Optionally remove keys listed in remove_keys regardless of their values.
226
+ """
227
+ if remove_keys is None:
228
+ remove_keys = set()
229
+ if isinstance(value, dict):
230
+ pruned: Dict[str, Any] = {}
231
+ for k, v in value.items():
232
+ if k in remove_keys:
233
+ continue
234
+ pruned_v = CommandMetadataAPI._prune_empty(v, remove_keys)
235
+ is_empty = (
236
+ pruned_v is None
237
+ or (isinstance(pruned_v, str) and pruned_v == "")
238
+ or (isinstance(pruned_v, (list, tuple, set)) and len(pruned_v) == 0)
239
+ or (isinstance(pruned_v, dict) and len(pruned_v) == 0)
240
+ )
241
+ # Preserve 'commands' key even if empty so headers still render
242
+ if k == "commands":
243
+ is_empty = False
244
+ if not is_empty:
245
+ pruned[k] = pruned_v
246
+ return pruned
247
+ if isinstance(value, list):
248
+ new_list = [CommandMetadataAPI._prune_empty(v, remove_keys) for v in value]
249
+ new_list = [v for v in new_list if not (
250
+ v is None
251
+ or (isinstance(v, str) and v == "")
252
+ or (isinstance(v, (list, tuple, set)) and len(v) == 0)
253
+ or (isinstance(v, dict) and len(v) == 0)
254
+ )]
255
+ return new_list
256
+ return value
257
+
258
+ @staticmethod
259
+ def _to_yaml_like(value: Any, indent: int = 0, omit_command_name: bool = False) -> str:
260
+ """Render a Python structure into a readable YAML-like string with custom formatting rules."""
261
+ indent_str = " " * indent
262
+ lines: List[str] = []
263
+ if isinstance(value, dict):
264
+ for k, v in value.items():
265
+ # 1) Omit the 'context' section entirely
266
+ if k == "context":
267
+ continue
268
+ # 2) Omit 'qualified_name' keys wherever they appear
269
+ if k == "qualified_name":
270
+ continue
271
+ # 3) Rename 'commands' header to 'Commands available'
272
+ # display_key = "Commands available" if k == "commands" else k
273
+
274
+ if isinstance(v, dict):
275
+ lines.extend(
276
+ (
277
+ # f"{indent_str}{display_key}:",
278
+ CommandMetadataAPI._to_yaml_like(v, indent + 1, omit_command_name=omit_command_name),
279
+ )
280
+ )
281
+ elif isinstance(v, list):
282
+ # Special rendering for command entries: always show header even if empty
283
+ if k == "commands":
284
+ # lines.append(f"{indent_str}{display_key}:")
285
+ for idx, item in enumerate(v):
286
+ if isinstance(item, dict):
287
+ if cmd_name := item.get("name", ""):
288
+ if not omit_command_name:
289
+ lines.append(f"{indent_str}- {cmd_name}")
290
+ if doc_text := item.get("doc_string"):
291
+ if doc_text := " ".join(
292
+ str(doc_text).split()
293
+ ).strip():
294
+ processed_cmd_name = cmd_name.replace('_', ' ').lower()
295
+ if processed_cmd_name not in doc_text.lower() and doc_text.lower() != processed_cmd_name:
296
+ if omit_command_name:
297
+ lines.append(f"{doc_text}")
298
+ else:
299
+ lines.append(f"{indent_str} {doc_text}")
300
+ elif not omit_command_name:
301
+ lines.append(f"{indent_str}-")
302
+
303
+ # Render remaining fields (excluding name and qualified_name)
304
+ for rk, rv in item.items():
305
+ if rk in {"name", "qualified_name", "doc_string", "plain_utterances"}:
306
+ continue
307
+
308
+ # 5) For inputs/outputs, render description first (fallback to name),
309
+ # omit 'name' and 'description' keys, and avoid bare '-' lines
310
+ if rk in {"inputs", "outputs"} and isinstance(rv, list):
311
+ if not rv:
312
+ continue
313
+ lines.append(f"{indent_str} {rk}:")
314
+ for param in rv:
315
+ if isinstance(param, dict):
316
+ desc_val = str(param.get("description", "") or "").strip()
317
+ name_val = str(param.get("name", "") or "").strip()
318
+ title_val = desc_val or name_val
319
+ type_val = str(param.get("type", "") or "").strip()
320
+ if not title_val:
321
+ # Skip to avoid a bare '-'
322
+ continue
323
+ # Render name/description and type on the same line
324
+ if type_val:
325
+ lines.append(f"{indent_str} - {title_val}, type: {type_val}")
326
+ else:
327
+ lines.append(f"{indent_str} - {title_val}")
328
+ for rk2, rv2 in param.items():
329
+ # We've already rendered name/description (and type) inline
330
+ if rk2 in {"name", "description", "type"}:
331
+ continue
332
+ # Render examples inline for readability
333
+ if rk2 == "examples" and isinstance(rv2, list):
334
+ try:
335
+ formatted = ", ".join([repr(x) for x in rv2])
336
+ except Exception:
337
+ formatted = ", ".join([str(x) for x in rv2])
338
+ lines.append(f"{indent_str} examples: [{formatted}]")
339
+ continue
340
+ if isinstance(rv2, (dict, list)):
341
+ lines.extend(
342
+ (
343
+ f"{indent_str} {rk2}:",
344
+ CommandMetadataAPI._to_yaml_like(rv2, indent + 3, omit_command_name=omit_command_name),
345
+ )
346
+ )
347
+ else:
348
+ lines.append(f"{indent_str} {rk2}: {rv2}")
349
+ else:
350
+ lines.append(f"{indent_str} - {param}")
351
+ elif isinstance(rv, dict):
352
+ lines.extend(
353
+ (
354
+ f"{indent_str} {('sample utterances' if rk == 'plain_utterances' else rk)}:",
355
+ CommandMetadataAPI._to_yaml_like(rv, indent + 2, omit_command_name=omit_command_name),
356
+ )
357
+ )
358
+ elif isinstance(rv, list):
359
+ if not rv:
360
+ continue
361
+ lines.append(f"{indent_str} {('sample utterances' if rk == 'plain_utterances' else rk)}:")
362
+ for sub in rv:
363
+ if isinstance(sub, dict):
364
+ sub_keys = list(sub.keys())
365
+ if not sub_keys:
366
+ continue
367
+ fkey = sub_keys[0]
368
+ fval = sub[fkey]
369
+ if isinstance(fval, (dict, list)):
370
+ lines.extend(
371
+ (
372
+ f"{indent_str} - {fkey}:",
373
+ CommandMetadataAPI._to_yaml_like(
374
+ fval, indent + 3, omit_command_name=omit_command_name
375
+ ),
376
+ )
377
+ )
378
+ else:
379
+ lines.append(f"{indent_str} - {fkey}: {fval}")
380
+ for rkk in sub_keys[1:]:
381
+ rvv = sub[rkk]
382
+ if isinstance(rvv, (dict, list)):
383
+ lines.extend(
384
+ (
385
+ f"{indent_str} {rkk}:",
386
+ CommandMetadataAPI._to_yaml_like(rvv, indent + 3, omit_command_name=omit_command_name),
387
+ )
388
+ )
389
+ else:
390
+ lines.append(f"{indent_str} {rkk}: {rvv}")
391
+ else:
392
+ lines.append(f"{indent_str} - {sub}")
393
+ else:
394
+ lines.append(f"{indent_str} {('sample utterances' if rk == 'plain_utterances' else rk)}: {rv}")
395
+
396
+ elif not omit_command_name:
397
+ lines.append(f"{indent_str}- {item}")
398
+ # 6) Separator line between commands (but not after the last)
399
+ if idx != len(v) - 1:
400
+ lines.append(f"{indent_str}")
401
+ else:
402
+ if len(v) == 0:
403
+ continue
404
+ # lines.append(f"{indent_str}{display_key}:")
405
+ # Default list rendering for non-command lists
406
+ for item in v:
407
+ if isinstance(item, dict):
408
+ item_keys = list(item.keys())
409
+ if not item_keys:
410
+ continue
411
+ first_key = item_keys[0]
412
+ first_val = item[first_key]
413
+ if isinstance(first_val, (dict, list)):
414
+ lines.append(f"{indent_str}- {first_key}:")
415
+ sub_item = {k2: item[k2] for k2 in item_keys if k2 != first_key}
416
+ if first_val:
417
+ lines.append(CommandMetadataAPI._to_yaml_like(first_val, indent + 2, omit_command_name=omit_command_name))
418
+ if sub_item:
419
+ lines.append(CommandMetadataAPI._to_yaml_like(sub_item, indent + 2, omit_command_name=omit_command_name))
420
+ else:
421
+ lines.append(f"{indent_str}- {first_key}: {first_val}")
422
+ for rk in item_keys[1:]:
423
+ rv = item[rk]
424
+ if isinstance(rv, (dict, list)):
425
+ lines.extend(
426
+ (
427
+ f"{indent_str} {rk}:",
428
+ CommandMetadataAPI._to_yaml_like(rv, indent + 2, omit_command_name=omit_command_name),
429
+ )
430
+ )
431
+ else:
432
+ lines.append(f"{indent_str} {rk}: {rv}")
433
+ else:
434
+ lines.append(f"{indent_str}- {item}")
435
+ else:
436
+ # lines.append(f"{indent_str}{display_key}: {v}")
437
+ lines.append(f"{indent_str}{v}")
438
+ return "\n".join(lines)
439
+
440
+ if isinstance(value, list):
441
+ for item in value:
442
+ if isinstance(item, dict):
443
+ item_keys = list(item.keys())
444
+ if not item_keys:
445
+ continue
446
+ first_key = item_keys[0]
447
+ first_val = item[first_key]
448
+ if isinstance(first_val, (dict, list)):
449
+ lines.extend(
450
+ (
451
+ f"{indent_str}- {first_key}:",
452
+ CommandMetadataAPI._to_yaml_like(
453
+ first_val, indent + 1, omit_command_name=omit_command_name
454
+ ),
455
+ )
456
+ )
457
+ else:
458
+ lines.append(f"{indent_str}- {first_key}: {first_val}")
459
+ for rk in item_keys[1:]:
460
+ rv = item[rk]
461
+ if isinstance(rv, (dict, list)):
462
+ lines.extend(
463
+ (
464
+ f"{indent_str} {rk}:",
465
+ CommandMetadataAPI._to_yaml_like(rv, indent + 2, omit_command_name=omit_command_name),
466
+ )
467
+ )
468
+ else:
469
+ lines.append(f"{indent_str} {rk}: {rv}")
470
+ elif isinstance(item, list):
471
+ lines.extend(
472
+ (
473
+ f"{indent_str}-",
474
+ CommandMetadataAPI._to_yaml_like(item, indent + 1, omit_command_name=omit_command_name),
475
+ )
476
+ )
477
+ else:
478
+ lines.append(f"{indent_str}- {item}")
479
+ return "\n".join(lines)
480
+
481
+ return f"{indent_str}{value}"
482
+
483
+ @staticmethod
484
+ def get_command_display_text(
485
+ subject_workflow_path: str,
486
+ cme_workflow_path: str,
487
+ active_context_name: str,
488
+ for_agents: bool = False,
489
+ ) -> str:
490
+ """
491
+ Return a YAML-like display text for commands in the given context.
492
+
493
+ - Calls get_enhanced_command_info to retrieve raw command metadata
494
+ - Removes empty fields/lists and the 'default' field
495
+ - Includes input 'pattern' only when for_agents=True
496
+ """
497
+ meta = CommandMetadataAPI.get_enhanced_command_info(
498
+ subject_workflow_path=subject_workflow_path,
499
+ cme_workflow_path=cme_workflow_path,
500
+ active_context_name=active_context_name,
501
+ )
502
+
503
+ # Build minimal context info (inheritance/containment if available)
504
+ context_info: Dict[str, Any] = {
505
+ "name": active_context_name,
506
+ "display_name": ("global" if active_context_name == "*" else active_context_name),
507
+ }
508
+
509
+ with contextlib.suppress(Exception):
510
+ inheritance_path = Path(subject_workflow_path) / "context_inheritance_model.json"
511
+ if inheritance_path.exists():
512
+ with open(inheritance_path) as f:
513
+ inheritance_data = json.load(f)
514
+ if vals := inheritance_data.get(active_context_name, []):
515
+ context_info["inheritance"] = vals
516
+ containment_path = Path(subject_workflow_path) / "context_containment_model.json"
517
+ if containment_path.exists():
518
+ with open(containment_path) as f:
519
+ containment_data = json.load(f)
520
+ if vals := containment_data.get(active_context_name, []):
521
+ context_info["containment"] = vals
522
+
523
+ # Massage commands for display
524
+ cmds = sorted(meta.get("commands", []), key=lambda x: x.get("name", ""))
525
+ # If there are no commands, preserve original behavior of rendering an empty header
526
+ if not cmds:
527
+ # Preserve header even when no commands are available
528
+ empty_display = {"context": context_info, "commands": []}
529
+ empty_display = CommandMetadataAPI._prune_empty(empty_display, remove_keys={"default"})
530
+ rendered = CommandMetadataAPI._to_yaml_like(empty_display, omit_command_name=False)
531
+ # Ensure a visible header is present
532
+ header = "Commands available:"
533
+ if not rendered.strip():
534
+ return header
535
+ return f"{header}\n{rendered}"
536
+
537
+ # Build the combined display by stitching per-command strings while keeping
538
+ # a single "Commands available:" header and blank lines between commands
539
+ parts: List[str] = []
540
+ for cmd in cmds:
541
+ fq = cmd.get("qualified_name", "")
542
+ if part := CommandMetadataAPI.get_command_display_text_for_command(
543
+ subject_workflow_path=subject_workflow_path,
544
+ cme_workflow_path=cme_workflow_path,
545
+ active_context_name=active_context_name,
546
+ qualified_command_name=fq,
547
+ for_agents=for_agents,
548
+ ):
549
+ parts.append(part)
550
+
551
+ if not parts:
552
+ empty_display = {"context": context_info, "commands": []}
553
+ empty_display = CommandMetadataAPI._prune_empty(empty_display, remove_keys={"default"})
554
+ return CommandMetadataAPI._to_yaml_like(empty_display, omit_command_name=False)
555
+
556
+ combined_lines: List[str] = ["Commands available:"]
557
+ for idx, text in enumerate(parts):
558
+ lines = text.splitlines()
559
+ if idx > 0:
560
+ # Insert a blank line as separator (mirrors original formatter)
561
+ combined_lines.append("")
562
+ combined_lines.extend(lines)
563
+
564
+ return "\n".join(combined_lines)
565
+
566
+ @staticmethod
567
+ def get_command_display_text_for_command(
568
+ subject_workflow_path: str,
569
+ cme_workflow_path: str,
570
+ active_context_name: str,
571
+ qualified_command_name: str,
572
+ for_agents: bool = False,
573
+ omit_command_name: bool = False
574
+ ) -> str:
575
+ """
576
+ Return a YAML-like display text for a single command in the given context.
577
+
578
+ Mirrors get_command_display_text but filters to a specific command only.
579
+ """
580
+ meta = CommandMetadataAPI.get_enhanced_command_info(
581
+ subject_workflow_path=subject_workflow_path,
582
+ cme_workflow_path=cme_workflow_path,
583
+ active_context_name=active_context_name,
584
+ )
585
+
586
+ target_cmd: Dict[str, Any] | None = next(
587
+ (
588
+ cmd
589
+ for cmd in meta.get("commands", [])
590
+ if cmd.get("qualified_name") == qualified_command_name
591
+ ),
592
+ None,
593
+ )
594
+ if target_cmd is None:
595
+ leaf = qualified_command_name.split("/")[-1]
596
+ for cmd in meta.get("commands", []):
597
+ if cmd.get("name") == leaf:
598
+ target_cmd = cmd
599
+ break
600
+
601
+ # Build minimal context info (inheritance/containment if available)
602
+ context_info: Dict[str, Any] = {
603
+ "name": active_context_name,
604
+ "display_name": ("global" if active_context_name == "*" else active_context_name),
605
+ }
606
+ with contextlib.suppress(Exception):
607
+ inheritance_path = Path(subject_workflow_path) / "context_inheritance_model.json"
608
+ if inheritance_path.exists():
609
+ with open(inheritance_path) as f:
610
+ inheritance_data = json.load(f)
611
+ if vals := inheritance_data.get(active_context_name, []):
612
+ context_info["inheritance"] = vals
613
+ containment_path = Path(subject_workflow_path) / "context_containment_model.json"
614
+ if containment_path.exists():
615
+ with open(containment_path) as f:
616
+ containment_data = json.load(f)
617
+ if vals := containment_data.get(active_context_name, []):
618
+ context_info["containment"] = vals
619
+
620
+ # If command not found, return empty string (no header here)
621
+ if target_cmd is None:
622
+ return ""
623
+
624
+ # Massage the single command for display (mirrors the formatter in the multi-command path)
625
+ new_cmd: Dict[str, Any] = {}
626
+ if "qualified_name" in target_cmd:
627
+ new_cmd["qualified_name"] = target_cmd["qualified_name"]
628
+ if "name" in target_cmd:
629
+ new_cmd["name"] = target_cmd["name"]
630
+ if doc_val := (target_cmd.get("doc_string") or "").strip():
631
+ new_cmd["doc_string"] = doc_val
632
+
633
+ inputs: List[Dict[str, Any]] = []
634
+ for inp in target_cmd.get("inputs", []) or []:
635
+ inp_out: Dict[str, Any] = {}
636
+ if "name" in inp:
637
+ inp_out["name"] = inp["name"]
638
+ if "type" in inp:
639
+ inp_out["type"] = CommandMetadataAPI._simplify_type_str(inp.get("type", ""))
640
+ if desc := inp.get("description", ""):
641
+ inp_out["description"] = desc
642
+ if examples := inp.get("examples", []) or []:
643
+ inp_out["examples"] = examples
644
+ if (af := inp.get("available_from", None)) is not None:
645
+ inp_out["available_from"] = af
646
+ if for_agents:
647
+ if pattern := inp.get("pattern", None):
648
+ if hasattr(pattern, 'pattern'):
649
+ pattern = pattern.pattern
650
+ inp_out["pattern"] = str(pattern)
651
+ inputs.append(inp_out)
652
+ if inputs:
653
+ new_cmd["inputs"] = inputs
654
+
655
+ outputs: List[Dict[str, Any]] = []
656
+ for outp in target_cmd.get("outputs", []) or []:
657
+ out_out: Dict[str, Any] = {}
658
+ if "name" in outp:
659
+ out_out["name"] = outp["name"]
660
+ if "type" in outp:
661
+ out_out["type"] = CommandMetadataAPI._simplify_type_str(outp.get("type", ""))
662
+ if desc := outp.get("description", ""):
663
+ out_out["description"] = desc
664
+ if examples := outp.get("examples", []) or []:
665
+ out_out["examples"] = examples
666
+ if (af := outp.get("available_from", None)) is not None:
667
+ out_out["available_from"] = af
668
+ outputs.append(out_out)
669
+ if outputs:
670
+ new_cmd["outputs"] = outputs
671
+
672
+ if utter := target_cmd.get("plain_utterances", []) or []:
673
+ new_cmd["plain_utterances"] = utter[:2]
674
+
675
+ display_obj: Dict[str, Any] = {
676
+ "context": context_info,
677
+ "commands": [new_cmd],
678
+ }
679
+ display_obj = CommandMetadataAPI._prune_empty(display_obj, remove_keys={"default"})
680
+ return CommandMetadataAPI._to_yaml_like(display_obj, omit_command_name=omit_command_name)
681
+
682
+ # ------------------------------------------------------------------
683
+ # Bulk metadata helpers
684
+ # ------------------------------------------------------------------
685
+ @staticmethod
686
+ def get_params_for_all_commands(workflow_path: str) -> Dict[str, Dict[str, List[Dict[str, Any]]]]:
687
+ """
688
+ Return a mapping of qualified command name -> {inputs: [...], outputs: [...]} where each param is a dict
689
+ with name, type_str, description, examples.
690
+ """
691
+ directory = CommandDirectory.load(workflow_path)
692
+
693
+ params_by_command: Dict[str, Dict[str, List[Dict[str, Any]]]] = {}
694
+ for qualified_name in directory.get_commands():
695
+ try:
696
+ # Ensure metadata is hydrated and import module by file path
697
+ directory.ensure_command_hydrated(qualified_name)
698
+ cmd_meta = directory.get_command_metadata(qualified_name)
699
+ module = python_utils.get_module(
700
+ cmd_meta.response_generation_module_path,
701
+ cmd_meta.workflow_folderpath or workflow_path,
702
+ )
703
+ except Exception:
704
+ continue
705
+ if not module or not hasattr(module, "Signature"):
706
+ continue
707
+ sig = module.Signature
708
+
709
+ inputs: List[Dict[str, Any]] = []
710
+ outputs: List[Dict[str, Any]] = []
711
+
712
+ InputModel = getattr(sig, "Input", None)
713
+ if InputModel is not None and hasattr(InputModel, "model_fields"):
714
+ inputs.extend(
715
+ {
716
+ "name": name,
717
+ "type_str": str(getattr(field, "annotation", "")),
718
+ "description": str(
719
+ getattr(field, "description", "") or ""
720
+ ),
721
+ "examples": list(getattr(field, "examples", []) or []),
722
+ "default": getattr(field, "default", None),
723
+ }
724
+ for name, field in InputModel.model_fields.items()
725
+ )
726
+ OutputModel = getattr(sig, "Output", None)
727
+ if OutputModel is not None and hasattr(OutputModel, "model_fields"):
728
+ outputs.extend(
729
+ {
730
+ "name": name,
731
+ "type_str": str(getattr(field, "annotation", "")),
732
+ "description": str(
733
+ getattr(field, "description", "") or ""
734
+ ),
735
+ "examples": list(getattr(field, "examples", []) or []),
736
+ }
737
+ for name, field in OutputModel.model_fields.items()
738
+ )
739
+ if inputs or outputs:
740
+ params_by_command[qualified_name] = {"inputs": inputs, "outputs": outputs}
741
+
742
+ return params_by_command
743
+
744
+ @staticmethod
745
+ def get_all_commands_metadata(workflow_path: str) -> List[Dict[str, Any]]:
746
+ """
747
+ Return a list of command metadata dicts suitable for documentation generation:
748
+ - command_name
749
+ - file_path
750
+ - plain_utterances
751
+ - input_model (name if exists)
752
+ - output_model (name if exists)
753
+ - docstring (module docstring if present)
754
+ - errors (empty list unless issues)
755
+ """
756
+ directory = CommandDirectory.load(workflow_path)
757
+
758
+ metadata_list: List[Dict[str, Any]] = []
759
+ for qualified_name in sorted(directory.get_commands()):
760
+ meta = {
761
+ "command_name": qualified_name.split("/")[-1],
762
+ "file_path": None,
763
+ "plain_utterances": [],
764
+ "input_model": None,
765
+ "output_model": None,
766
+ "docstring": None,
767
+ "errors": [],
768
+ }
769
+ try:
770
+ cmd_meta = directory.get_command_metadata(qualified_name)
771
+ meta["file_path"] = cmd_meta.response_generation_module_path
772
+
773
+ if module := python_utils.get_module(
774
+ cmd_meta.response_generation_module_path,
775
+ cmd_meta.workflow_folderpath or workflow_path,
776
+ ):
777
+ if module_doc := inspect.getdoc(module) or getattr(module, "__doc__", None):
778
+ meta["docstring"] = module_doc
779
+
780
+ sig = getattr(module, "Signature", None)
781
+ if sig is not None:
782
+ if hasattr(sig, "Input"):
783
+ meta["input_model"] = "Input"
784
+ if hasattr(sig, "Output"):
785
+ meta["output_model"] = "Output"
786
+ if hasattr(sig, "plain_utterances"):
787
+ meta["plain_utterances"] = list(getattr(sig, "plain_utterances") or [])
788
+
789
+ except Exception as e:
790
+ meta["errors"].append(str(e))
791
+
792
+ metadata_list.append(meta)
793
+
794
+ return metadata_list