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.
- fastworkflow/_workflows/command_metadata_extraction/_commands/ErrorCorrection/you_misunderstood.py +1 -1
- fastworkflow/_workflows/command_metadata_extraction/_commands/IntentDetection/go_up.py +1 -1
- fastworkflow/_workflows/command_metadata_extraction/_commands/IntentDetection/reset_context.py +1 -1
- fastworkflow/_workflows/command_metadata_extraction/_commands/IntentDetection/what_can_i_do.py +98 -166
- fastworkflow/_workflows/command_metadata_extraction/_commands/wildcard.py +7 -3
- fastworkflow/build/genai_postprocessor.py +143 -149
- fastworkflow/chat_session.py +42 -11
- fastworkflow/command_metadata_api.py +794 -0
- fastworkflow/command_routing.py +4 -1
- fastworkflow/examples/fastworkflow.env +1 -1
- fastworkflow/examples/fastworkflow.passwords.env +1 -0
- fastworkflow/examples/hello_world/_commands/add_two_numbers.py +1 -0
- fastworkflow/examples/retail_workflow/_commands/calculate.py +67 -0
- fastworkflow/examples/retail_workflow/_commands/cancel_pending_order.py +4 -1
- fastworkflow/examples/retail_workflow/_commands/exchange_delivered_order_items.py +13 -1
- fastworkflow/examples/retail_workflow/_commands/find_user_id_by_email.py +6 -1
- fastworkflow/examples/retail_workflow/_commands/find_user_id_by_name_zip.py +6 -1
- fastworkflow/examples/retail_workflow/_commands/get_order_details.py +22 -10
- fastworkflow/examples/retail_workflow/_commands/get_product_details.py +12 -4
- fastworkflow/examples/retail_workflow/_commands/get_user_details.py +21 -5
- fastworkflow/examples/retail_workflow/_commands/list_all_product_types.py +4 -1
- fastworkflow/examples/retail_workflow/_commands/modify_pending_order_address.py +3 -0
- fastworkflow/examples/retail_workflow/_commands/modify_pending_order_items.py +12 -0
- fastworkflow/examples/retail_workflow/_commands/modify_pending_order_payment.py +7 -1
- fastworkflow/examples/retail_workflow/_commands/modify_user_address.py +3 -0
- fastworkflow/examples/retail_workflow/_commands/return_delivered_order_items.py +10 -1
- fastworkflow/examples/retail_workflow/_commands/transfer_to_human_agents.py +1 -1
- fastworkflow/examples/retail_workflow/tools/calculate.py +1 -1
- fastworkflow/mcp_server.py +52 -44
- fastworkflow/run/__main__.py +9 -5
- fastworkflow/run_agent/__main__.py +8 -8
- fastworkflow/run_agent/agent_module.py +6 -16
- fastworkflow/utils/command_dependency_graph.py +130 -143
- fastworkflow/utils/dspy_utils.py +11 -0
- fastworkflow/utils/signatures.py +7 -0
- fastworkflow/workflow_agent.py +186 -0
- {fastworkflow-2.13.5.dist-info → fastworkflow-2.14.1.dist-info}/METADATA +12 -3
- {fastworkflow-2.13.5.dist-info → fastworkflow-2.14.1.dist-info}/RECORD +41 -40
- fastworkflow/agent_integration.py +0 -239
- fastworkflow/examples/retail_workflow/_commands/parameter_dependency_graph.json +0 -36
- {fastworkflow-2.13.5.dist-info → fastworkflow-2.14.1.dist-info}/LICENSE +0 -0
- {fastworkflow-2.13.5.dist-info → fastworkflow-2.14.1.dist-info}/WHEEL +0 -0
- {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
|