hexdag 0.5.0.dev1__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.
- hexdag/__init__.py +116 -0
- hexdag/__main__.py +30 -0
- hexdag/adapters/executors/__init__.py +5 -0
- hexdag/adapters/executors/local_executor.py +316 -0
- hexdag/builtin/__init__.py +6 -0
- hexdag/builtin/adapters/__init__.py +51 -0
- hexdag/builtin/adapters/anthropic/__init__.py +5 -0
- hexdag/builtin/adapters/anthropic/anthropic_adapter.py +151 -0
- hexdag/builtin/adapters/database/__init__.py +6 -0
- hexdag/builtin/adapters/database/csv/csv_adapter.py +249 -0
- hexdag/builtin/adapters/database/pgvector/__init__.py +5 -0
- hexdag/builtin/adapters/database/pgvector/pgvector_adapter.py +478 -0
- hexdag/builtin/adapters/database/sqlalchemy/sqlalchemy_adapter.py +252 -0
- hexdag/builtin/adapters/database/sqlite/__init__.py +5 -0
- hexdag/builtin/adapters/database/sqlite/sqlite_adapter.py +410 -0
- hexdag/builtin/adapters/local/README.md +59 -0
- hexdag/builtin/adapters/local/__init__.py +7 -0
- hexdag/builtin/adapters/local/local_observer_manager.py +696 -0
- hexdag/builtin/adapters/memory/__init__.py +47 -0
- hexdag/builtin/adapters/memory/file_memory_adapter.py +297 -0
- hexdag/builtin/adapters/memory/in_memory_memory.py +216 -0
- hexdag/builtin/adapters/memory/schemas.py +57 -0
- hexdag/builtin/adapters/memory/session_memory.py +178 -0
- hexdag/builtin/adapters/memory/sqlite_memory_adapter.py +215 -0
- hexdag/builtin/adapters/memory/state_memory.py +280 -0
- hexdag/builtin/adapters/mock/README.md +89 -0
- hexdag/builtin/adapters/mock/__init__.py +15 -0
- hexdag/builtin/adapters/mock/hexdag.toml +50 -0
- hexdag/builtin/adapters/mock/mock_database.py +225 -0
- hexdag/builtin/adapters/mock/mock_embedding.py +223 -0
- hexdag/builtin/adapters/mock/mock_llm.py +177 -0
- hexdag/builtin/adapters/mock/mock_tool_adapter.py +192 -0
- hexdag/builtin/adapters/mock/mock_tool_router.py +232 -0
- hexdag/builtin/adapters/openai/__init__.py +5 -0
- hexdag/builtin/adapters/openai/openai_adapter.py +634 -0
- hexdag/builtin/adapters/secret/__init__.py +7 -0
- hexdag/builtin/adapters/secret/local_secret_adapter.py +248 -0
- hexdag/builtin/adapters/unified_tool_router.py +280 -0
- hexdag/builtin/macros/__init__.py +17 -0
- hexdag/builtin/macros/conversation_agent.py +390 -0
- hexdag/builtin/macros/llm_macro.py +151 -0
- hexdag/builtin/macros/reasoning_agent.py +423 -0
- hexdag/builtin/macros/tool_macro.py +380 -0
- hexdag/builtin/nodes/__init__.py +38 -0
- hexdag/builtin/nodes/_discovery.py +123 -0
- hexdag/builtin/nodes/agent_node.py +696 -0
- hexdag/builtin/nodes/base_node_factory.py +242 -0
- hexdag/builtin/nodes/composite_node.py +926 -0
- hexdag/builtin/nodes/data_node.py +201 -0
- hexdag/builtin/nodes/expression_node.py +487 -0
- hexdag/builtin/nodes/function_node.py +454 -0
- hexdag/builtin/nodes/llm_node.py +491 -0
- hexdag/builtin/nodes/loop_node.py +920 -0
- hexdag/builtin/nodes/mapped_input.py +518 -0
- hexdag/builtin/nodes/port_call_node.py +269 -0
- hexdag/builtin/nodes/tool_call_node.py +195 -0
- hexdag/builtin/nodes/tool_utils.py +390 -0
- hexdag/builtin/prompts/__init__.py +68 -0
- hexdag/builtin/prompts/base.py +422 -0
- hexdag/builtin/prompts/chat_prompts.py +303 -0
- hexdag/builtin/prompts/error_correction_prompts.py +320 -0
- hexdag/builtin/prompts/tool_prompts.py +160 -0
- hexdag/builtin/tools/builtin_tools.py +84 -0
- hexdag/builtin/tools/database_tools.py +164 -0
- hexdag/cli/__init__.py +17 -0
- hexdag/cli/__main__.py +7 -0
- hexdag/cli/commands/__init__.py +27 -0
- hexdag/cli/commands/build_cmd.py +812 -0
- hexdag/cli/commands/create_cmd.py +208 -0
- hexdag/cli/commands/docs_cmd.py +293 -0
- hexdag/cli/commands/generate_types_cmd.py +252 -0
- hexdag/cli/commands/init_cmd.py +188 -0
- hexdag/cli/commands/pipeline_cmd.py +494 -0
- hexdag/cli/commands/plugin_dev_cmd.py +529 -0
- hexdag/cli/commands/plugins_cmd.py +441 -0
- hexdag/cli/commands/studio_cmd.py +101 -0
- hexdag/cli/commands/validate_cmd.py +221 -0
- hexdag/cli/main.py +84 -0
- hexdag/core/__init__.py +83 -0
- hexdag/core/config/__init__.py +20 -0
- hexdag/core/config/loader.py +479 -0
- hexdag/core/config/models.py +150 -0
- hexdag/core/configurable.py +294 -0
- hexdag/core/context/__init__.py +37 -0
- hexdag/core/context/execution_context.py +378 -0
- hexdag/core/docs/__init__.py +26 -0
- hexdag/core/docs/extractors.py +678 -0
- hexdag/core/docs/generators.py +890 -0
- hexdag/core/docs/models.py +120 -0
- hexdag/core/domain/__init__.py +10 -0
- hexdag/core/domain/dag.py +1225 -0
- hexdag/core/exceptions.py +234 -0
- hexdag/core/expression_parser.py +569 -0
- hexdag/core/logging.py +449 -0
- hexdag/core/models/__init__.py +17 -0
- hexdag/core/models/base.py +138 -0
- hexdag/core/orchestration/__init__.py +46 -0
- hexdag/core/orchestration/body_executor.py +481 -0
- hexdag/core/orchestration/components/__init__.py +97 -0
- hexdag/core/orchestration/components/adapter_lifecycle_manager.py +113 -0
- hexdag/core/orchestration/components/checkpoint_manager.py +134 -0
- hexdag/core/orchestration/components/execution_coordinator.py +360 -0
- hexdag/core/orchestration/components/health_check_manager.py +176 -0
- hexdag/core/orchestration/components/input_mapper.py +143 -0
- hexdag/core/orchestration/components/lifecycle_manager.py +583 -0
- hexdag/core/orchestration/components/node_executor.py +377 -0
- hexdag/core/orchestration/components/secret_manager.py +202 -0
- hexdag/core/orchestration/components/wave_executor.py +158 -0
- hexdag/core/orchestration/constants.py +17 -0
- hexdag/core/orchestration/events/README.md +312 -0
- hexdag/core/orchestration/events/__init__.py +104 -0
- hexdag/core/orchestration/events/batching.py +330 -0
- hexdag/core/orchestration/events/decorators.py +139 -0
- hexdag/core/orchestration/events/events.py +573 -0
- hexdag/core/orchestration/events/observers/__init__.py +30 -0
- hexdag/core/orchestration/events/observers/core_observers.py +690 -0
- hexdag/core/orchestration/events/observers/models.py +111 -0
- hexdag/core/orchestration/events/taxonomy.py +269 -0
- hexdag/core/orchestration/hook_context.py +237 -0
- hexdag/core/orchestration/hooks.py +437 -0
- hexdag/core/orchestration/models.py +418 -0
- hexdag/core/orchestration/orchestrator.py +910 -0
- hexdag/core/orchestration/orchestrator_factory.py +275 -0
- hexdag/core/orchestration/port_wrappers.py +327 -0
- hexdag/core/orchestration/prompt/__init__.py +32 -0
- hexdag/core/orchestration/prompt/template.py +332 -0
- hexdag/core/pipeline_builder/__init__.py +21 -0
- hexdag/core/pipeline_builder/component_instantiator.py +386 -0
- hexdag/core/pipeline_builder/include_tag.py +265 -0
- hexdag/core/pipeline_builder/pipeline_config.py +133 -0
- hexdag/core/pipeline_builder/py_tag.py +223 -0
- hexdag/core/pipeline_builder/tag_discovery.py +268 -0
- hexdag/core/pipeline_builder/yaml_builder.py +1196 -0
- hexdag/core/pipeline_builder/yaml_validator.py +569 -0
- hexdag/core/ports/__init__.py +65 -0
- hexdag/core/ports/api_call.py +133 -0
- hexdag/core/ports/database.py +489 -0
- hexdag/core/ports/embedding.py +215 -0
- hexdag/core/ports/executor.py +237 -0
- hexdag/core/ports/file_storage.py +117 -0
- hexdag/core/ports/healthcheck.py +87 -0
- hexdag/core/ports/llm.py +551 -0
- hexdag/core/ports/memory.py +70 -0
- hexdag/core/ports/observer_manager.py +130 -0
- hexdag/core/ports/secret.py +145 -0
- hexdag/core/ports/tool_router.py +94 -0
- hexdag/core/ports_builder.py +623 -0
- hexdag/core/protocols.py +273 -0
- hexdag/core/resolver.py +304 -0
- hexdag/core/schema/__init__.py +9 -0
- hexdag/core/schema/generator.py +742 -0
- hexdag/core/secrets.py +242 -0
- hexdag/core/types.py +413 -0
- hexdag/core/utils/async_warnings.py +206 -0
- hexdag/core/utils/schema_conversion.py +78 -0
- hexdag/core/utils/sql_validation.py +86 -0
- hexdag/core/validation/secure_json.py +148 -0
- hexdag/core/yaml_macro.py +517 -0
- hexdag/mcp_server.py +3120 -0
- hexdag/studio/__init__.py +10 -0
- hexdag/studio/build_ui.py +92 -0
- hexdag/studio/server/__init__.py +1 -0
- hexdag/studio/server/main.py +100 -0
- hexdag/studio/server/routes/__init__.py +9 -0
- hexdag/studio/server/routes/execute.py +208 -0
- hexdag/studio/server/routes/export.py +558 -0
- hexdag/studio/server/routes/files.py +207 -0
- hexdag/studio/server/routes/plugins.py +419 -0
- hexdag/studio/server/routes/validate.py +220 -0
- hexdag/studio/ui/index.html +13 -0
- hexdag/studio/ui/package-lock.json +2992 -0
- hexdag/studio/ui/package.json +31 -0
- hexdag/studio/ui/postcss.config.js +6 -0
- hexdag/studio/ui/public/hexdag.svg +5 -0
- hexdag/studio/ui/src/App.tsx +251 -0
- hexdag/studio/ui/src/components/Canvas.tsx +408 -0
- hexdag/studio/ui/src/components/ContextMenu.tsx +187 -0
- hexdag/studio/ui/src/components/FileBrowser.tsx +123 -0
- hexdag/studio/ui/src/components/Header.tsx +181 -0
- hexdag/studio/ui/src/components/HexdagNode.tsx +193 -0
- hexdag/studio/ui/src/components/NodeInspector.tsx +512 -0
- hexdag/studio/ui/src/components/NodePalette.tsx +262 -0
- hexdag/studio/ui/src/components/NodePortsSection.tsx +403 -0
- hexdag/studio/ui/src/components/PluginManager.tsx +347 -0
- hexdag/studio/ui/src/components/PortsEditor.tsx +481 -0
- hexdag/studio/ui/src/components/PythonEditor.tsx +195 -0
- hexdag/studio/ui/src/components/ValidationPanel.tsx +105 -0
- hexdag/studio/ui/src/components/YamlEditor.tsx +196 -0
- hexdag/studio/ui/src/components/index.ts +8 -0
- hexdag/studio/ui/src/index.css +92 -0
- hexdag/studio/ui/src/main.tsx +10 -0
- hexdag/studio/ui/src/types/index.ts +123 -0
- hexdag/studio/ui/src/vite-env.d.ts +1 -0
- hexdag/studio/ui/tailwind.config.js +29 -0
- hexdag/studio/ui/tsconfig.json +37 -0
- hexdag/studio/ui/tsconfig.node.json +13 -0
- hexdag/studio/ui/vite.config.ts +35 -0
- hexdag/visualization/__init__.py +69 -0
- hexdag/visualization/dag_visualizer.py +1020 -0
- hexdag-0.5.0.dev1.dist-info/METADATA +369 -0
- hexdag-0.5.0.dev1.dist-info/RECORD +261 -0
- hexdag-0.5.0.dev1.dist-info/WHEEL +4 -0
- hexdag-0.5.0.dev1.dist-info/entry_points.txt +4 -0
- hexdag-0.5.0.dev1.dist-info/licenses/LICENSE +190 -0
- hexdag_plugins/.gitignore +43 -0
- hexdag_plugins/README.md +73 -0
- hexdag_plugins/__init__.py +1 -0
- hexdag_plugins/azure/LICENSE +21 -0
- hexdag_plugins/azure/README.md +414 -0
- hexdag_plugins/azure/__init__.py +21 -0
- hexdag_plugins/azure/azure_blob_adapter.py +450 -0
- hexdag_plugins/azure/azure_cosmos_adapter.py +383 -0
- hexdag_plugins/azure/azure_keyvault_adapter.py +314 -0
- hexdag_plugins/azure/azure_openai_adapter.py +415 -0
- hexdag_plugins/azure/pyproject.toml +107 -0
- hexdag_plugins/azure/tests/__init__.py +1 -0
- hexdag_plugins/azure/tests/test_azure_blob_adapter.py +350 -0
- hexdag_plugins/azure/tests/test_azure_cosmos_adapter.py +323 -0
- hexdag_plugins/azure/tests/test_azure_keyvault_adapter.py +330 -0
- hexdag_plugins/azure/tests/test_azure_openai_adapter.py +329 -0
- hexdag_plugins/hexdag_etl/README.md +168 -0
- hexdag_plugins/hexdag_etl/__init__.py +53 -0
- hexdag_plugins/hexdag_etl/examples/01_simple_pandas_transform.py +270 -0
- hexdag_plugins/hexdag_etl/examples/02_simple_pandas_only.py +149 -0
- hexdag_plugins/hexdag_etl/examples/03_file_io_pipeline.py +109 -0
- hexdag_plugins/hexdag_etl/examples/test_pandas_transform.py +84 -0
- hexdag_plugins/hexdag_etl/hexdag.toml +25 -0
- hexdag_plugins/hexdag_etl/hexdag_etl/__init__.py +48 -0
- hexdag_plugins/hexdag_etl/hexdag_etl/nodes/__init__.py +13 -0
- hexdag_plugins/hexdag_etl/hexdag_etl/nodes/api_extract.py +230 -0
- hexdag_plugins/hexdag_etl/hexdag_etl/nodes/base_node_factory.py +181 -0
- hexdag_plugins/hexdag_etl/hexdag_etl/nodes/file_io.py +415 -0
- hexdag_plugins/hexdag_etl/hexdag_etl/nodes/outlook.py +492 -0
- hexdag_plugins/hexdag_etl/hexdag_etl/nodes/pandas_transform.py +563 -0
- hexdag_plugins/hexdag_etl/hexdag_etl/nodes/sql_extract_load.py +112 -0
- hexdag_plugins/hexdag_etl/pyproject.toml +82 -0
- hexdag_plugins/hexdag_etl/test_transform.py +54 -0
- hexdag_plugins/hexdag_etl/tests/test_plugin_integration.py +62 -0
- hexdag_plugins/mysql_adapter/LICENSE +21 -0
- hexdag_plugins/mysql_adapter/README.md +224 -0
- hexdag_plugins/mysql_adapter/__init__.py +6 -0
- hexdag_plugins/mysql_adapter/mysql_adapter.py +408 -0
- hexdag_plugins/mysql_adapter/pyproject.toml +93 -0
- hexdag_plugins/mysql_adapter/tests/test_mysql_adapter.py +259 -0
- hexdag_plugins/storage/README.md +184 -0
- hexdag_plugins/storage/__init__.py +19 -0
- hexdag_plugins/storage/file/__init__.py +5 -0
- hexdag_plugins/storage/file/local.py +325 -0
- hexdag_plugins/storage/ports/__init__.py +5 -0
- hexdag_plugins/storage/ports/vector_store.py +236 -0
- hexdag_plugins/storage/sql/__init__.py +7 -0
- hexdag_plugins/storage/sql/base.py +187 -0
- hexdag_plugins/storage/sql/mysql.py +27 -0
- hexdag_plugins/storage/sql/postgresql.py +27 -0
- hexdag_plugins/storage/tests/__init__.py +1 -0
- hexdag_plugins/storage/tests/test_local_file_storage.py +161 -0
- hexdag_plugins/storage/tests/test_sql_adapters.py +212 -0
- hexdag_plugins/storage/vector/__init__.py +7 -0
- hexdag_plugins/storage/vector/chromadb.py +223 -0
- hexdag_plugins/storage/vector/in_memory.py +285 -0
- hexdag_plugins/storage/vector/pgvector.py +502 -0
hexdag/mcp_server.py
ADDED
|
@@ -0,0 +1,3120 @@
|
|
|
1
|
+
"""MCP (Model Context Protocol) server for hexDAG.
|
|
2
|
+
|
|
3
|
+
Exposes hexDAG functionality as MCP tools for LLM-powered editors like Claude Code and Cursor.
|
|
4
|
+
This server enables LLMs to:
|
|
5
|
+
- Discover available components by scanning builtin modules
|
|
6
|
+
- Build YAML pipelines with guided, structured approaches
|
|
7
|
+
- Validate pipeline configurations
|
|
8
|
+
- Generate pipeline templates
|
|
9
|
+
|
|
10
|
+
Components are discovered by scanning module contents (no registry needed).
|
|
11
|
+
|
|
12
|
+
Installation
|
|
13
|
+
------------
|
|
14
|
+
uv add "hexdag[mcp]"
|
|
15
|
+
|
|
16
|
+
Usage
|
|
17
|
+
-----
|
|
18
|
+
Development mode::
|
|
19
|
+
|
|
20
|
+
uv run mcp dev hexdag/mcp_server.py
|
|
21
|
+
|
|
22
|
+
Install for Claude Desktop/Cursor::
|
|
23
|
+
|
|
24
|
+
uv run mcp install hexdag/mcp_server.py --name hexdag
|
|
25
|
+
|
|
26
|
+
Example Claude Desktop config::
|
|
27
|
+
|
|
28
|
+
{
|
|
29
|
+
"mcpServers": {
|
|
30
|
+
"hexdag": {
|
|
31
|
+
"command": "uv",
|
|
32
|
+
"args": ["run", "python", "-m", "hexdag.mcp_server"]
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
"""
|
|
37
|
+
|
|
38
|
+
from __future__ import annotations
|
|
39
|
+
|
|
40
|
+
import json
|
|
41
|
+
from enum import Enum
|
|
42
|
+
from pathlib import Path
|
|
43
|
+
from typing import Any
|
|
44
|
+
|
|
45
|
+
import yaml
|
|
46
|
+
from mcp.server.fastmcp import FastMCP
|
|
47
|
+
|
|
48
|
+
from hexdag.core.pipeline_builder import YamlPipelineBuilder
|
|
49
|
+
from hexdag.core.pipeline_builder.tag_discovery import discover_tags, get_tag_schema
|
|
50
|
+
from hexdag.core.resolver import ResolveError, resolve
|
|
51
|
+
from hexdag.core.schema import SchemaGenerator
|
|
52
|
+
|
|
53
|
+
# Generated documentation directory
|
|
54
|
+
_GENERATED_DOCS_DIR = Path(__file__).parent.parent / "docs" / "generated" / "mcp"
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def _load_generated_doc(filename: str) -> str | None:
|
|
58
|
+
"""Load generated documentation from file.
|
|
59
|
+
|
|
60
|
+
Parameters
|
|
61
|
+
----------
|
|
62
|
+
filename : str
|
|
63
|
+
Name of the documentation file (e.g., "adapter_guide.md")
|
|
64
|
+
|
|
65
|
+
Returns
|
|
66
|
+
-------
|
|
67
|
+
str | None
|
|
68
|
+
File contents if exists, None otherwise
|
|
69
|
+
"""
|
|
70
|
+
doc_path = _GENERATED_DOCS_DIR / filename
|
|
71
|
+
if doc_path.exists():
|
|
72
|
+
return doc_path.read_text()
|
|
73
|
+
return None
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def _create_pipeline_base(
|
|
77
|
+
name: str,
|
|
78
|
+
description: str = "",
|
|
79
|
+
ports: dict[str, Any] | None = None,
|
|
80
|
+
) -> dict[str, Any]:
|
|
81
|
+
"""Create base pipeline configuration structure.
|
|
82
|
+
|
|
83
|
+
Centralizes pipeline structure creation to avoid duplication.
|
|
84
|
+
|
|
85
|
+
Parameters
|
|
86
|
+
----------
|
|
87
|
+
name : str
|
|
88
|
+
Pipeline name (metadata.name)
|
|
89
|
+
description : str, optional
|
|
90
|
+
Pipeline description
|
|
91
|
+
ports : dict[str, Any] | None, optional
|
|
92
|
+
Port configurations (llm, memory, etc.)
|
|
93
|
+
|
|
94
|
+
Returns
|
|
95
|
+
-------
|
|
96
|
+
dict[str, Any]
|
|
97
|
+
Pipeline configuration dict ready for adding nodes
|
|
98
|
+
"""
|
|
99
|
+
config: dict[str, Any] = {
|
|
100
|
+
"apiVersion": "hexdag/v1",
|
|
101
|
+
"kind": "Pipeline",
|
|
102
|
+
"metadata": {
|
|
103
|
+
"name": name,
|
|
104
|
+
},
|
|
105
|
+
"spec": {
|
|
106
|
+
"nodes": [],
|
|
107
|
+
},
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
if description:
|
|
111
|
+
config["metadata"]["description"] = description
|
|
112
|
+
|
|
113
|
+
if ports:
|
|
114
|
+
config["spec"]["ports"] = ports
|
|
115
|
+
|
|
116
|
+
return config
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
# Create MCP server
|
|
120
|
+
mcp = FastMCP(
|
|
121
|
+
"hexDAG",
|
|
122
|
+
dependencies=["pydantic>=2.0", "pyyaml>=6.0", "jinja2>=3.1.0"],
|
|
123
|
+
)
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
# ============================================================================
|
|
127
|
+
# Component Discovery (Module-based instead of registry)
|
|
128
|
+
# ============================================================================
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def _discover_components_in_module(
|
|
132
|
+
module: Any, suffix: str | None = None, base_class: type | None = None
|
|
133
|
+
) -> list[dict[str, Any]]:
|
|
134
|
+
"""Discover components in a module by class name convention or base class.
|
|
135
|
+
|
|
136
|
+
Parameters
|
|
137
|
+
----------
|
|
138
|
+
module : Any
|
|
139
|
+
Module to scan
|
|
140
|
+
suffix : str | None
|
|
141
|
+
Class name suffix to filter by (e.g., "Node", "Adapter")
|
|
142
|
+
base_class : type | None
|
|
143
|
+
Base class to filter by (alternative to suffix)
|
|
144
|
+
|
|
145
|
+
Returns
|
|
146
|
+
-------
|
|
147
|
+
list[dict[str, Any]]
|
|
148
|
+
List of component info dicts
|
|
149
|
+
"""
|
|
150
|
+
result = []
|
|
151
|
+
|
|
152
|
+
for name in dir(module):
|
|
153
|
+
if name.startswith("_"):
|
|
154
|
+
continue
|
|
155
|
+
|
|
156
|
+
obj = getattr(module, name, None)
|
|
157
|
+
if obj is None or not isinstance(obj, type):
|
|
158
|
+
continue
|
|
159
|
+
|
|
160
|
+
# Filter by suffix or base class
|
|
161
|
+
matches = False
|
|
162
|
+
if suffix and name.endswith(suffix):
|
|
163
|
+
matches = True
|
|
164
|
+
if base_class and issubclass(obj, base_class) and obj is not base_class:
|
|
165
|
+
matches = True
|
|
166
|
+
|
|
167
|
+
if matches:
|
|
168
|
+
result.append({
|
|
169
|
+
"name": name,
|
|
170
|
+
"module": f"{module.__name__}.{name}",
|
|
171
|
+
"description": (obj.__doc__ or "").split("\n")[0].strip(),
|
|
172
|
+
})
|
|
173
|
+
|
|
174
|
+
return result
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
# ============================================================================
|
|
178
|
+
# Component Discovery Tools (Dynamic from Registry)
|
|
179
|
+
# ============================================================================
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
@mcp.tool() # type: ignore[misc]
|
|
183
|
+
def list_nodes() -> str:
|
|
184
|
+
"""List all available node types with auto-generated documentation.
|
|
185
|
+
|
|
186
|
+
Returns detailed information about each node type including:
|
|
187
|
+
- Node name, namespace, and module path
|
|
188
|
+
- Description (from docstring)
|
|
189
|
+
- Parameters summary (from _yaml_schema if available)
|
|
190
|
+
- Required vs optional parameters
|
|
191
|
+
|
|
192
|
+
Returns
|
|
193
|
+
-------
|
|
194
|
+
JSON string with available nodes grouped by namespace
|
|
195
|
+
|
|
196
|
+
Examples
|
|
197
|
+
--------
|
|
198
|
+
>>> list_nodes() # doctest: +SKIP
|
|
199
|
+
{
|
|
200
|
+
"core": [
|
|
201
|
+
{
|
|
202
|
+
"name": "ConditionalNode",
|
|
203
|
+
"namespace": "core",
|
|
204
|
+
"module_path": "hexdag.builtin.nodes.ConditionalNode",
|
|
205
|
+
"description": "Multi-branch conditional router...",
|
|
206
|
+
"parameters": {
|
|
207
|
+
"required": ["branches"],
|
|
208
|
+
"optional": ["else_action", "tie_break"]
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
]
|
|
212
|
+
}
|
|
213
|
+
"""
|
|
214
|
+
from hexdag.builtin import nodes as builtin_nodes
|
|
215
|
+
|
|
216
|
+
nodes_by_namespace: dict[str, list[dict[str, Any]]] = {"core": []}
|
|
217
|
+
|
|
218
|
+
# Scan builtin nodes module
|
|
219
|
+
for name in dir(builtin_nodes):
|
|
220
|
+
if name.startswith("_"):
|
|
221
|
+
continue
|
|
222
|
+
|
|
223
|
+
obj = getattr(builtin_nodes, name, None)
|
|
224
|
+
if obj is None or not isinstance(obj, type):
|
|
225
|
+
continue
|
|
226
|
+
|
|
227
|
+
# Check if it's a node class (ends with Node or has BaseNodeFactory parent)
|
|
228
|
+
if not name.endswith("Node"):
|
|
229
|
+
continue
|
|
230
|
+
|
|
231
|
+
# Extract description from _yaml_schema or docstring
|
|
232
|
+
yaml_schema = getattr(obj, "_yaml_schema", None)
|
|
233
|
+
if yaml_schema and isinstance(yaml_schema, dict):
|
|
234
|
+
description = yaml_schema.get(
|
|
235
|
+
"description", (obj.__doc__ or "No description").split("\n")[0].strip()
|
|
236
|
+
)
|
|
237
|
+
# Extract parameter info from schema
|
|
238
|
+
properties = yaml_schema.get("properties", {})
|
|
239
|
+
required = yaml_schema.get("required", [])
|
|
240
|
+
optional = [k for k in properties if k not in required]
|
|
241
|
+
params_info = {"required": required, "optional": optional}
|
|
242
|
+
else:
|
|
243
|
+
description = (obj.__doc__ or "No description available").split("\n")[0].strip()
|
|
244
|
+
params_info = None
|
|
245
|
+
|
|
246
|
+
node_info: dict[str, Any] = {
|
|
247
|
+
"name": name,
|
|
248
|
+
"namespace": "core",
|
|
249
|
+
"module_path": f"hexdag.builtin.nodes.{name}",
|
|
250
|
+
"description": description,
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
if params_info:
|
|
254
|
+
node_info["parameters"] = params_info
|
|
255
|
+
|
|
256
|
+
nodes_by_namespace["core"].append(node_info)
|
|
257
|
+
|
|
258
|
+
return json.dumps(nodes_by_namespace, indent=2)
|
|
259
|
+
|
|
260
|
+
|
|
261
|
+
@mcp.tool() # type: ignore[misc]
|
|
262
|
+
def list_adapters(port_type: str | None = None) -> str:
|
|
263
|
+
"""List all available adapters in the hexDAG registry.
|
|
264
|
+
|
|
265
|
+
Args
|
|
266
|
+
----
|
|
267
|
+
port_type: Optional filter by port type (e.g., "llm", "memory", "database", "secret")
|
|
268
|
+
|
|
269
|
+
Returns
|
|
270
|
+
-------
|
|
271
|
+
JSON string with available adapters grouped by port type
|
|
272
|
+
|
|
273
|
+
Examples
|
|
274
|
+
--------
|
|
275
|
+
>>> list_adapters(port_type="llm") # doctest: +SKIP
|
|
276
|
+
{
|
|
277
|
+
"llm": [
|
|
278
|
+
{
|
|
279
|
+
"name": "openai",
|
|
280
|
+
"namespace": "core",
|
|
281
|
+
"port_type": "llm",
|
|
282
|
+
"description": "OpenAI LLM adapter"
|
|
283
|
+
}
|
|
284
|
+
]
|
|
285
|
+
}
|
|
286
|
+
"""
|
|
287
|
+
from hexdag.builtin import adapters as builtin_adapters
|
|
288
|
+
|
|
289
|
+
adapters_by_port: dict[str, list[dict[str, Any]]] = {}
|
|
290
|
+
|
|
291
|
+
# Scan builtin adapters module
|
|
292
|
+
for name in dir(builtin_adapters):
|
|
293
|
+
if name.startswith("_"):
|
|
294
|
+
continue
|
|
295
|
+
|
|
296
|
+
obj = getattr(builtin_adapters, name, None)
|
|
297
|
+
if obj is None or not isinstance(obj, type):
|
|
298
|
+
continue
|
|
299
|
+
|
|
300
|
+
# Check if it's an adapter class
|
|
301
|
+
if not name.endswith("Adapter"):
|
|
302
|
+
continue
|
|
303
|
+
|
|
304
|
+
# Guess port type from name
|
|
305
|
+
guessed_port = _guess_port_type_from_name(name)
|
|
306
|
+
|
|
307
|
+
# Filter by port type if specified
|
|
308
|
+
if port_type and guessed_port != port_type:
|
|
309
|
+
continue
|
|
310
|
+
|
|
311
|
+
if guessed_port not in adapters_by_port:
|
|
312
|
+
adapters_by_port[guessed_port] = []
|
|
313
|
+
|
|
314
|
+
adapter_info = {
|
|
315
|
+
"name": name,
|
|
316
|
+
"namespace": "core",
|
|
317
|
+
"module_path": f"hexdag.builtin.adapters.{name}",
|
|
318
|
+
"port_type": guessed_port,
|
|
319
|
+
"description": (obj.__doc__ or "No description available").split("\n")[0].strip(),
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
adapters_by_port[guessed_port].append(adapter_info)
|
|
323
|
+
|
|
324
|
+
return json.dumps(adapters_by_port, indent=2)
|
|
325
|
+
|
|
326
|
+
|
|
327
|
+
def _guess_port_type_from_name(adapter_name: str) -> str:
|
|
328
|
+
"""Guess port type from adapter class name."""
|
|
329
|
+
name_lower = adapter_name.lower()
|
|
330
|
+
if "llm" in name_lower or "openai" in name_lower or "anthropic" in name_lower:
|
|
331
|
+
return "llm"
|
|
332
|
+
if "memory" in name_lower:
|
|
333
|
+
return "memory"
|
|
334
|
+
if "database" in name_lower or "sql" in name_lower:
|
|
335
|
+
return "database"
|
|
336
|
+
if "secret" in name_lower or "keyvault" in name_lower:
|
|
337
|
+
return "secret"
|
|
338
|
+
if "storage" in name_lower or "blob" in name_lower:
|
|
339
|
+
return "storage"
|
|
340
|
+
if "tool" in name_lower:
|
|
341
|
+
return "tool_router"
|
|
342
|
+
return "unknown"
|
|
343
|
+
|
|
344
|
+
|
|
345
|
+
@mcp.tool() # type: ignore[misc]
|
|
346
|
+
def list_tools(namespace: str | None = None) -> str:
|
|
347
|
+
"""List all available tools in the hexDAG registry.
|
|
348
|
+
|
|
349
|
+
Args
|
|
350
|
+
----
|
|
351
|
+
namespace: Optional filter by namespace (e.g., "core", "user", "plugin")
|
|
352
|
+
|
|
353
|
+
Returns
|
|
354
|
+
-------
|
|
355
|
+
JSON string with available tools and their schemas
|
|
356
|
+
|
|
357
|
+
Examples
|
|
358
|
+
--------
|
|
359
|
+
>>> list_tools(namespace="core") # doctest: +SKIP
|
|
360
|
+
{
|
|
361
|
+
"core": [
|
|
362
|
+
{
|
|
363
|
+
"name": "tool_end",
|
|
364
|
+
"namespace": "core",
|
|
365
|
+
"description": "End agent execution"
|
|
366
|
+
}
|
|
367
|
+
]
|
|
368
|
+
}
|
|
369
|
+
"""
|
|
370
|
+
from hexdag.builtin.tools import builtin_tools
|
|
371
|
+
|
|
372
|
+
tools_by_namespace: dict[str, list[dict[str, Any]]] = {"core": []}
|
|
373
|
+
|
|
374
|
+
# Filter by namespace if specified (only core namespace for builtin)
|
|
375
|
+
if namespace and namespace != "core":
|
|
376
|
+
return json.dumps(tools_by_namespace, indent=2)
|
|
377
|
+
|
|
378
|
+
# Scan builtin tools module
|
|
379
|
+
for name in dir(builtin_tools):
|
|
380
|
+
if name.startswith("_"):
|
|
381
|
+
continue
|
|
382
|
+
|
|
383
|
+
obj = getattr(builtin_tools, name, None)
|
|
384
|
+
if obj is None:
|
|
385
|
+
continue
|
|
386
|
+
|
|
387
|
+
# Check if it's a callable (function or class)
|
|
388
|
+
if not callable(obj):
|
|
389
|
+
continue
|
|
390
|
+
|
|
391
|
+
# Skip non-tool items
|
|
392
|
+
if name in ("Any", "TypeVar"):
|
|
393
|
+
continue
|
|
394
|
+
|
|
395
|
+
tool_info = {
|
|
396
|
+
"name": name,
|
|
397
|
+
"namespace": "core",
|
|
398
|
+
"module_path": f"hexdag.builtin.tools.{name}",
|
|
399
|
+
"description": (obj.__doc__ or "No description available").split("\n")[0].strip(),
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
tools_by_namespace["core"].append(tool_info)
|
|
403
|
+
|
|
404
|
+
return json.dumps(tools_by_namespace, indent=2)
|
|
405
|
+
|
|
406
|
+
|
|
407
|
+
@mcp.tool() # type: ignore[misc]
|
|
408
|
+
def list_macros() -> str:
|
|
409
|
+
"""List all available macros in the hexDAG registry.
|
|
410
|
+
|
|
411
|
+
Macros are reusable pipeline templates that expand into subgraphs.
|
|
412
|
+
|
|
413
|
+
Returns
|
|
414
|
+
-------
|
|
415
|
+
JSON string with available macros and their descriptions
|
|
416
|
+
|
|
417
|
+
Examples
|
|
418
|
+
--------
|
|
419
|
+
>>> list_macros() # doctest: +SKIP
|
|
420
|
+
[
|
|
421
|
+
{
|
|
422
|
+
"name": "reasoning_agent",
|
|
423
|
+
"namespace": "core",
|
|
424
|
+
"description": "ReAct reasoning agent pattern"
|
|
425
|
+
}
|
|
426
|
+
]
|
|
427
|
+
"""
|
|
428
|
+
from hexdag.builtin import macros as builtin_macros
|
|
429
|
+
|
|
430
|
+
macros_list: list[dict[str, Any]] = []
|
|
431
|
+
|
|
432
|
+
# Scan builtin macros module
|
|
433
|
+
for name in dir(builtin_macros):
|
|
434
|
+
if name.startswith("_"):
|
|
435
|
+
continue
|
|
436
|
+
|
|
437
|
+
obj = getattr(builtin_macros, name, None)
|
|
438
|
+
if obj is None or not isinstance(obj, type):
|
|
439
|
+
continue
|
|
440
|
+
|
|
441
|
+
# Check if it's a macro class (ends with Macro or has ConfigurableMacro parent)
|
|
442
|
+
if not name.endswith("Macro"):
|
|
443
|
+
continue
|
|
444
|
+
|
|
445
|
+
macro_info = {
|
|
446
|
+
"name": name,
|
|
447
|
+
"namespace": "core",
|
|
448
|
+
"module_path": f"hexdag.builtin.macros.{name}",
|
|
449
|
+
"description": (obj.__doc__ or "No description available").split("\n")[0].strip(),
|
|
450
|
+
}
|
|
451
|
+
macros_list.append(macro_info)
|
|
452
|
+
|
|
453
|
+
return json.dumps(macros_list, indent=2)
|
|
454
|
+
|
|
455
|
+
|
|
456
|
+
@mcp.tool() # type: ignore[misc]
|
|
457
|
+
def list_tags() -> str:
|
|
458
|
+
"""List all available YAML custom tags.
|
|
459
|
+
|
|
460
|
+
Returns detailed information about each tag including:
|
|
461
|
+
- Tag name (e.g., "!py", "!include")
|
|
462
|
+
- Description
|
|
463
|
+
- Module path
|
|
464
|
+
- Syntax examples
|
|
465
|
+
- Security warnings (if applicable)
|
|
466
|
+
|
|
467
|
+
Returns
|
|
468
|
+
-------
|
|
469
|
+
JSON string with available tags and their documentation
|
|
470
|
+
|
|
471
|
+
Examples
|
|
472
|
+
--------
|
|
473
|
+
>>> list_tags() # doctest: +SKIP
|
|
474
|
+
{
|
|
475
|
+
"!py": {
|
|
476
|
+
"name": "!py",
|
|
477
|
+
"description": "Compile inline Python code into callable functions",
|
|
478
|
+
"module": "hexdag.core.pipeline_builder.py_tag",
|
|
479
|
+
"syntax": ["!py | <python_code> # Inline Python code block"],
|
|
480
|
+
"is_registered": true,
|
|
481
|
+
"security_warning": "Executes arbitrary Python code..."
|
|
482
|
+
},
|
|
483
|
+
"!include": {
|
|
484
|
+
"name": "!include",
|
|
485
|
+
"description": "Include content from external YAML files",
|
|
486
|
+
"module": "hexdag.core.pipeline_builder.include_tag",
|
|
487
|
+
"syntax": ["!include ./path/to/file.yaml"],
|
|
488
|
+
"is_registered": true
|
|
489
|
+
}
|
|
490
|
+
}
|
|
491
|
+
"""
|
|
492
|
+
tags = discover_tags()
|
|
493
|
+
|
|
494
|
+
# Format for MCP response - include essential info
|
|
495
|
+
result: dict[str, dict[str, Any]] = {}
|
|
496
|
+
for tag_name, tag_info in tags.items():
|
|
497
|
+
result[tag_name] = {
|
|
498
|
+
"name": tag_info["name"],
|
|
499
|
+
"description": tag_info["description"],
|
|
500
|
+
"module": tag_info["module"],
|
|
501
|
+
"syntax": tag_info["syntax"],
|
|
502
|
+
"is_registered": tag_info["is_registered"],
|
|
503
|
+
}
|
|
504
|
+
|
|
505
|
+
# Add security warning for !py
|
|
506
|
+
if tag_name == "!py":
|
|
507
|
+
result[tag_name]["security_warning"] = (
|
|
508
|
+
"Executes arbitrary Python code. Only use with trusted YAML files."
|
|
509
|
+
)
|
|
510
|
+
|
|
511
|
+
return json.dumps(result, indent=2)
|
|
512
|
+
|
|
513
|
+
|
|
514
|
+
@mcp.tool() # type: ignore[misc]
|
|
515
|
+
def get_component_schema(
|
|
516
|
+
component_type: str,
|
|
517
|
+
name: str,
|
|
518
|
+
namespace: str = "core",
|
|
519
|
+
) -> str:
|
|
520
|
+
"""Get detailed auto-generated schema for a specific component.
|
|
521
|
+
|
|
522
|
+
Auto-extracts documentation from:
|
|
523
|
+
- _yaml_schema class attribute (preferred, with full descriptions)
|
|
524
|
+
- __call__ method signature (fallback)
|
|
525
|
+
- Class/function docstrings
|
|
526
|
+
|
|
527
|
+
Args
|
|
528
|
+
----
|
|
529
|
+
component_type: Type of component (node, adapter, tool, macro, policy, tag)
|
|
530
|
+
name: Component name (class name, full module path, or tag name like "!py")
|
|
531
|
+
namespace: Component namespace (default: "core") - ignored for tags
|
|
532
|
+
|
|
533
|
+
Returns
|
|
534
|
+
-------
|
|
535
|
+
JSON string with:
|
|
536
|
+
- schema: Full JSON schema with property descriptions
|
|
537
|
+
- parameters: Detailed parameter documentation
|
|
538
|
+
- yaml_example: Ready-to-use YAML example
|
|
539
|
+
- documentation: Full docstring
|
|
540
|
+
|
|
541
|
+
Examples
|
|
542
|
+
--------
|
|
543
|
+
>>> get_component_schema("node", "ConditionalNode", "core") # doctest: +SKIP
|
|
544
|
+
{
|
|
545
|
+
"name": "ConditionalNode",
|
|
546
|
+
"type": "node",
|
|
547
|
+
"schema": {...},
|
|
548
|
+
"parameters": [
|
|
549
|
+
{"name": "branches", "type": "array", "required": true, "description": "..."}
|
|
550
|
+
],
|
|
551
|
+
"yaml_example": "..."
|
|
552
|
+
}
|
|
553
|
+
|
|
554
|
+
>>> get_component_schema("tag", "!py") # doctest: +SKIP
|
|
555
|
+
{
|
|
556
|
+
"name": "!py",
|
|
557
|
+
"type": "tag",
|
|
558
|
+
"description": "Compile inline Python code into callable functions",
|
|
559
|
+
"schema": {...},
|
|
560
|
+
"yaml_example": "body: !py |\\n async def process(...):\\n return item * 2"
|
|
561
|
+
}
|
|
562
|
+
"""
|
|
563
|
+
# Handle tag component type
|
|
564
|
+
if component_type == "tag":
|
|
565
|
+
return _get_tag_schema_response(name)
|
|
566
|
+
|
|
567
|
+
try:
|
|
568
|
+
# If name is a full module path, resolve directly
|
|
569
|
+
if "." in name:
|
|
570
|
+
component_obj = resolve(name)
|
|
571
|
+
else:
|
|
572
|
+
# Try to resolve from builtin modules based on component type
|
|
573
|
+
module_map = {
|
|
574
|
+
"node": "hexdag.builtin.nodes",
|
|
575
|
+
"adapter": "hexdag.builtin.adapters",
|
|
576
|
+
"tool": "hexdag.builtin.tools",
|
|
577
|
+
"macro": "hexdag.builtin.macros",
|
|
578
|
+
"policy": "hexdag.builtin.policies",
|
|
579
|
+
}
|
|
580
|
+
|
|
581
|
+
base_module = module_map.get(component_type)
|
|
582
|
+
if not base_module:
|
|
583
|
+
raise ResolveError(name, f"Unknown component type: {component_type}")
|
|
584
|
+
|
|
585
|
+
# Try to resolve with full path
|
|
586
|
+
full_path = f"{base_module}.{name}"
|
|
587
|
+
component_obj = resolve(full_path)
|
|
588
|
+
|
|
589
|
+
# Check for explicit _yaml_schema (preferred - has full descriptions)
|
|
590
|
+
yaml_schema = getattr(component_obj, "_yaml_schema", None)
|
|
591
|
+
|
|
592
|
+
if yaml_schema and isinstance(yaml_schema, dict):
|
|
593
|
+
# Use explicit schema with full documentation
|
|
594
|
+
schema = yaml_schema
|
|
595
|
+
|
|
596
|
+
# Extract detailed parameter documentation
|
|
597
|
+
parameters = _extract_parameters_from_schema(yaml_schema)
|
|
598
|
+
|
|
599
|
+
# Generate rich YAML example from schema
|
|
600
|
+
yaml_example = _generate_yaml_example_from_schema(name, yaml_schema)
|
|
601
|
+
else:
|
|
602
|
+
# Fall back to signature introspection
|
|
603
|
+
if isinstance(component_obj, type):
|
|
604
|
+
try:
|
|
605
|
+
component_instance = component_obj()
|
|
606
|
+
schema = SchemaGenerator.from_callable(component_instance) # type: ignore[arg-type]
|
|
607
|
+
except TypeError:
|
|
608
|
+
schema = SchemaGenerator.from_callable(component_obj) # type: ignore[arg-type]
|
|
609
|
+
else:
|
|
610
|
+
schema = SchemaGenerator.from_callable(component_obj) # type: ignore[arg-type]
|
|
611
|
+
|
|
612
|
+
parameters = _extract_parameters_from_schema(schema) if isinstance(schema, dict) else []
|
|
613
|
+
yaml_example = (
|
|
614
|
+
SchemaGenerator.generate_example_yaml(name, schema)
|
|
615
|
+
if isinstance(schema, dict) and schema
|
|
616
|
+
else ""
|
|
617
|
+
)
|
|
618
|
+
|
|
619
|
+
# Extract documentation from docstring
|
|
620
|
+
doc = ""
|
|
621
|
+
if hasattr(component_obj, "__doc__") and component_obj.__doc__:
|
|
622
|
+
doc = component_obj.__doc__.strip()
|
|
623
|
+
|
|
624
|
+
result = {
|
|
625
|
+
"name": name,
|
|
626
|
+
"namespace": namespace,
|
|
627
|
+
"type": component_type,
|
|
628
|
+
"schema": schema,
|
|
629
|
+
"parameters": parameters,
|
|
630
|
+
"yaml_example": yaml_example,
|
|
631
|
+
"documentation": doc,
|
|
632
|
+
}
|
|
633
|
+
|
|
634
|
+
return json.dumps(result, indent=2)
|
|
635
|
+
|
|
636
|
+
except Exception as e:
|
|
637
|
+
return json.dumps(
|
|
638
|
+
{
|
|
639
|
+
"error": str(e),
|
|
640
|
+
"component_type": component_type,
|
|
641
|
+
"name": name,
|
|
642
|
+
"namespace": namespace,
|
|
643
|
+
},
|
|
644
|
+
indent=2,
|
|
645
|
+
)
|
|
646
|
+
|
|
647
|
+
|
|
648
|
+
def _get_tag_schema_response(name: str) -> str:
|
|
649
|
+
"""Get schema information for a YAML custom tag.
|
|
650
|
+
|
|
651
|
+
Parameters
|
|
652
|
+
----------
|
|
653
|
+
name : str
|
|
654
|
+
Tag name (e.g., "!py" or "!include")
|
|
655
|
+
|
|
656
|
+
Returns
|
|
657
|
+
-------
|
|
658
|
+
str
|
|
659
|
+
JSON string with tag schema information
|
|
660
|
+
"""
|
|
661
|
+
try:
|
|
662
|
+
# Normalize tag name (add ! prefix if missing)
|
|
663
|
+
tag_name = name if name.startswith("!") else f"!{name}"
|
|
664
|
+
|
|
665
|
+
schema = get_tag_schema(tag_name)
|
|
666
|
+
|
|
667
|
+
# Generate YAML examples based on tag type
|
|
668
|
+
yaml_examples: dict[str, str] = {
|
|
669
|
+
"!py": """body: !py |
|
|
670
|
+
async def process(item, index, state, **ports):
|
|
671
|
+
'''Process an item.'''
|
|
672
|
+
return item * 2""",
|
|
673
|
+
"!include": """# Simple include:
|
|
674
|
+
nodes:
|
|
675
|
+
- !include ./shared/validation_nodes.yaml
|
|
676
|
+
|
|
677
|
+
# Include with variables:
|
|
678
|
+
nodes:
|
|
679
|
+
- !include
|
|
680
|
+
path: ./templates/processor.yaml
|
|
681
|
+
vars:
|
|
682
|
+
node_name: custom_processor
|
|
683
|
+
timeout: 30""",
|
|
684
|
+
}
|
|
685
|
+
|
|
686
|
+
result: dict[str, Any] = {
|
|
687
|
+
"name": schema["name"],
|
|
688
|
+
"type": "tag",
|
|
689
|
+
"namespace": "core",
|
|
690
|
+
"description": schema["description"],
|
|
691
|
+
"schema": schema.get("input_schema", {}),
|
|
692
|
+
"output": schema.get("output", {}),
|
|
693
|
+
"documentation": schema.get("documentation", ""),
|
|
694
|
+
"syntax": schema.get("syntax", []),
|
|
695
|
+
"yaml_example": yaml_examples.get(tag_name, ""),
|
|
696
|
+
}
|
|
697
|
+
|
|
698
|
+
# Add security warning if present
|
|
699
|
+
if "security_warning" in schema:
|
|
700
|
+
result["security_warning"] = schema["security_warning"]
|
|
701
|
+
|
|
702
|
+
return json.dumps(result, indent=2)
|
|
703
|
+
|
|
704
|
+
except ValueError as e:
|
|
705
|
+
return json.dumps(
|
|
706
|
+
{
|
|
707
|
+
"error": str(e),
|
|
708
|
+
"component_type": "tag",
|
|
709
|
+
"name": name,
|
|
710
|
+
},
|
|
711
|
+
indent=2,
|
|
712
|
+
)
|
|
713
|
+
except Exception as e:
|
|
714
|
+
return json.dumps(
|
|
715
|
+
{
|
|
716
|
+
"error": str(e),
|
|
717
|
+
"component_type": "tag",
|
|
718
|
+
"name": name,
|
|
719
|
+
},
|
|
720
|
+
indent=2,
|
|
721
|
+
)
|
|
722
|
+
|
|
723
|
+
|
|
724
|
+
def _extract_parameters_from_schema(schema: dict[str, Any]) -> list[dict[str, Any]]:
|
|
725
|
+
"""Extract detailed parameter documentation from JSON schema.
|
|
726
|
+
|
|
727
|
+
Args
|
|
728
|
+
----
|
|
729
|
+
schema: JSON schema dict with properties
|
|
730
|
+
|
|
731
|
+
Returns
|
|
732
|
+
-------
|
|
733
|
+
List of parameter dicts with name, type, required, default, description
|
|
734
|
+
"""
|
|
735
|
+
parameters = []
|
|
736
|
+
properties = schema.get("properties", {})
|
|
737
|
+
required = set(schema.get("required", []))
|
|
738
|
+
|
|
739
|
+
for prop_name, prop_schema in properties.items():
|
|
740
|
+
param: dict[str, Any] = {
|
|
741
|
+
"name": prop_name,
|
|
742
|
+
"type": prop_schema.get("type", "any"),
|
|
743
|
+
"required": prop_name in required,
|
|
744
|
+
}
|
|
745
|
+
|
|
746
|
+
if "description" in prop_schema:
|
|
747
|
+
param["description"] = prop_schema["description"]
|
|
748
|
+
|
|
749
|
+
if "default" in prop_schema:
|
|
750
|
+
param["default"] = prop_schema["default"]
|
|
751
|
+
|
|
752
|
+
if "enum" in prop_schema:
|
|
753
|
+
param["allowed_values"] = prop_schema["enum"]
|
|
754
|
+
|
|
755
|
+
# Handle nested objects (like branch items)
|
|
756
|
+
if prop_schema.get("type") == "array" and "items" in prop_schema:
|
|
757
|
+
items_schema = prop_schema["items"]
|
|
758
|
+
if items_schema.get("type") == "object" and "properties" in items_schema:
|
|
759
|
+
param["item_properties"] = list(items_schema["properties"].keys())
|
|
760
|
+
|
|
761
|
+
parameters.append(param)
|
|
762
|
+
|
|
763
|
+
return parameters
|
|
764
|
+
|
|
765
|
+
|
|
766
|
+
def _generate_yaml_example_from_schema(node_name: str, schema: dict[str, Any]) -> str:
|
|
767
|
+
"""Generate a rich YAML example from schema with comments.
|
|
768
|
+
|
|
769
|
+
Args
|
|
770
|
+
----
|
|
771
|
+
node_name: Name of the node type
|
|
772
|
+
schema: JSON schema dict
|
|
773
|
+
|
|
774
|
+
Returns
|
|
775
|
+
-------
|
|
776
|
+
YAML string with example values and comments
|
|
777
|
+
"""
|
|
778
|
+
properties = schema.get("properties", {})
|
|
779
|
+
required = set(schema.get("required", []))
|
|
780
|
+
|
|
781
|
+
# Build example spec
|
|
782
|
+
spec: dict[str, Any] = {}
|
|
783
|
+
|
|
784
|
+
for prop_name, prop_schema in properties.items():
|
|
785
|
+
prop_type = prop_schema.get("type", "string")
|
|
786
|
+
|
|
787
|
+
# Use default if available
|
|
788
|
+
if "default" in prop_schema:
|
|
789
|
+
if prop_name in required:
|
|
790
|
+
spec[prop_name] = prop_schema["default"]
|
|
791
|
+
continue # Skip optional with defaults
|
|
792
|
+
|
|
793
|
+
# Generate example value based on type
|
|
794
|
+
if prop_type == "string":
|
|
795
|
+
if "enum" in prop_schema:
|
|
796
|
+
spec[prop_name] = prop_schema["enum"][0]
|
|
797
|
+
else:
|
|
798
|
+
spec[prop_name] = f"<{prop_name}>"
|
|
799
|
+
elif prop_type == "integer":
|
|
800
|
+
spec[prop_name] = 0
|
|
801
|
+
elif prop_type == "number":
|
|
802
|
+
spec[prop_name] = 0.0
|
|
803
|
+
elif prop_type == "boolean":
|
|
804
|
+
spec[prop_name] = False
|
|
805
|
+
elif prop_type == "array":
|
|
806
|
+
items = prop_schema.get("items", {})
|
|
807
|
+
if items.get("type") == "object":
|
|
808
|
+
# Generate example array item
|
|
809
|
+
item_props = items.get("properties", {})
|
|
810
|
+
example_item = {}
|
|
811
|
+
for item_key, item_schema in item_props.items():
|
|
812
|
+
if item_schema.get("type") == "string":
|
|
813
|
+
example_item[item_key] = f"<{item_key}>"
|
|
814
|
+
else:
|
|
815
|
+
example_item[item_key] = f"<{item_key}>"
|
|
816
|
+
spec[prop_name] = [example_item]
|
|
817
|
+
else:
|
|
818
|
+
spec[prop_name] = []
|
|
819
|
+
elif prop_type == "object":
|
|
820
|
+
spec[prop_name] = {}
|
|
821
|
+
else:
|
|
822
|
+
spec[prop_name] = f"<{prop_name}>"
|
|
823
|
+
|
|
824
|
+
# Build full YAML structure
|
|
825
|
+
example = {
|
|
826
|
+
"kind": node_name.lower().replace("node", "_node") if "Node" in node_name else node_name,
|
|
827
|
+
"metadata": {"name": f"my_{node_name.lower().replace('node', '')}"},
|
|
828
|
+
"spec": spec,
|
|
829
|
+
"dependencies": [],
|
|
830
|
+
}
|
|
831
|
+
|
|
832
|
+
return yaml.dump(example, sort_keys=False, default_flow_style=False)
|
|
833
|
+
|
|
834
|
+
|
|
835
|
+
@mcp.tool() # type: ignore[misc]
|
|
836
|
+
def get_syntax_reference() -> str:
|
|
837
|
+
"""Get reference for hexDAG YAML syntax including variable references.
|
|
838
|
+
|
|
839
|
+
Returns comprehensive documentation on:
|
|
840
|
+
- $input.field - Reference initial pipeline input
|
|
841
|
+
- {{node.output}} - Jinja2 template for node outputs
|
|
842
|
+
- ${ENV_VAR} - Environment variables
|
|
843
|
+
- input_mapping syntax and usage
|
|
844
|
+
|
|
845
|
+
Returns
|
|
846
|
+
-------
|
|
847
|
+
Detailed syntax reference documentation
|
|
848
|
+
|
|
849
|
+
Examples
|
|
850
|
+
--------
|
|
851
|
+
>>> get_syntax_reference() # doctest: +SKIP
|
|
852
|
+
# hexDAG Variable Reference Syntax
|
|
853
|
+
...
|
|
854
|
+
"""
|
|
855
|
+
# Try to load auto-generated documentation first
|
|
856
|
+
generated = _load_generated_doc("syntax_reference.md")
|
|
857
|
+
if generated:
|
|
858
|
+
return generated
|
|
859
|
+
|
|
860
|
+
# Fallback to static documentation
|
|
861
|
+
return """# hexDAG Variable Reference Syntax
|
|
862
|
+
|
|
863
|
+
## 1. Initial Input Reference: $input
|
|
864
|
+
|
|
865
|
+
Use `$input.field` in `input_mapping` to access the original pipeline input.
|
|
866
|
+
This allows passing data from the initial request through multiple pipeline stages.
|
|
867
|
+
|
|
868
|
+
```yaml
|
|
869
|
+
nodes:
|
|
870
|
+
- kind: function_node
|
|
871
|
+
metadata:
|
|
872
|
+
name: processor
|
|
873
|
+
spec:
|
|
874
|
+
fn: myapp.process
|
|
875
|
+
input_mapping:
|
|
876
|
+
load_id: $input.load_id # Gets initial input's load_id
|
|
877
|
+
carrier: $input.carrier_mc # Gets initial input's carrier_mc
|
|
878
|
+
dependencies: [extractor]
|
|
879
|
+
```
|
|
880
|
+
|
|
881
|
+
**Key Points:**
|
|
882
|
+
- `$input` refers to the ENTIRE initial pipeline input
|
|
883
|
+
- `$input.field` extracts a specific field from initial input
|
|
884
|
+
- Works regardless of node dependencies
|
|
885
|
+
- Useful for passing request context through the pipeline
|
|
886
|
+
|
|
887
|
+
## 2. Node Output Reference in Prompt Templates: {{node.field}}
|
|
888
|
+
|
|
889
|
+
Use Jinja2 syntax in prompt templates to reference previous node outputs.
|
|
890
|
+
|
|
891
|
+
```yaml
|
|
892
|
+
- kind: llm_node
|
|
893
|
+
metadata:
|
|
894
|
+
name: analyzer
|
|
895
|
+
spec:
|
|
896
|
+
prompt_template: |
|
|
897
|
+
Analyze this data:
|
|
898
|
+
{{extractor.result}}
|
|
899
|
+
|
|
900
|
+
Previous analysis:
|
|
901
|
+
{{validator.summary}}
|
|
902
|
+
```
|
|
903
|
+
|
|
904
|
+
**Key Points:**
|
|
905
|
+
- Double curly braces `{{}}` for Jinja2 templates
|
|
906
|
+
- `{{node_name.field}}` extracts field from named node's output
|
|
907
|
+
- Only available in `prompt_template` fields
|
|
908
|
+
- Resolved at runtime during LLM call
|
|
909
|
+
|
|
910
|
+
## 3. Environment Variables: ${VAR}
|
|
911
|
+
|
|
912
|
+
Environment variables are resolved in two phases:
|
|
913
|
+
|
|
914
|
+
### Non-Secrets (Build-time resolution)
|
|
915
|
+
```yaml
|
|
916
|
+
spec:
|
|
917
|
+
ports:
|
|
918
|
+
llm:
|
|
919
|
+
config:
|
|
920
|
+
model: ${MODEL} # Resolved when YAML is parsed
|
|
921
|
+
timeout: ${TIMEOUT:30} # Default value if not set
|
|
922
|
+
```
|
|
923
|
+
|
|
924
|
+
### Secrets (Runtime resolution)
|
|
925
|
+
Secret-like variables are deferred to runtime for security:
|
|
926
|
+
```yaml
|
|
927
|
+
spec:
|
|
928
|
+
ports:
|
|
929
|
+
llm:
|
|
930
|
+
config:
|
|
931
|
+
api_key: ${OPENAI_API_KEY} # Resolved when adapter is created
|
|
932
|
+
```
|
|
933
|
+
|
|
934
|
+
**Secret Patterns (deferred to runtime):**
|
|
935
|
+
- `*_API_KEY` (e.g., OPENAI_API_KEY)
|
|
936
|
+
- `*_SECRET` (e.g., DB_SECRET)
|
|
937
|
+
- `*_TOKEN` (e.g., AUTH_TOKEN)
|
|
938
|
+
- `*_PASSWORD` (e.g., DB_PASSWORD)
|
|
939
|
+
- `*_CREDENTIAL` (e.g., SERVICE_CREDENTIAL)
|
|
940
|
+
- `SECRET_*` (e.g., SECRET_KEY)
|
|
941
|
+
|
|
942
|
+
**Default Values:**
|
|
943
|
+
- `${VAR:default}` - Use "default" if VAR is not set
|
|
944
|
+
- `${VAR:}` - Use empty string if VAR is not set
|
|
945
|
+
|
|
946
|
+
## 4. Input Mapping
|
|
947
|
+
|
|
948
|
+
The `input_mapping` field transforms input data for a node:
|
|
949
|
+
|
|
950
|
+
```yaml
|
|
951
|
+
- kind: function_node
|
|
952
|
+
metadata:
|
|
953
|
+
name: merger
|
|
954
|
+
spec:
|
|
955
|
+
fn: myapp.merge_results
|
|
956
|
+
input_mapping:
|
|
957
|
+
# From initial pipeline input
|
|
958
|
+
request_id: $input.id
|
|
959
|
+
|
|
960
|
+
# From specific dependency outputs
|
|
961
|
+
analysis: analyzer.result
|
|
962
|
+
validation_status: validator.is_valid
|
|
963
|
+
|
|
964
|
+
# Nested path extraction
|
|
965
|
+
score: analyzer.metadata.confidence_score
|
|
966
|
+
dependencies: [analyzer, validator]
|
|
967
|
+
```
|
|
968
|
+
|
|
969
|
+
**Mapping Sources:**
|
|
970
|
+
- `$input.path` - Extract from initial pipeline input
|
|
971
|
+
- `$input` - Entire initial input
|
|
972
|
+
- `node_name.path` - Extract from specific node's output
|
|
973
|
+
- `field_name` - Extract from base input (single dependency case)
|
|
974
|
+
|
|
975
|
+
## 5. Node Aliases
|
|
976
|
+
|
|
977
|
+
Define short aliases for node module paths:
|
|
978
|
+
|
|
979
|
+
```yaml
|
|
980
|
+
spec:
|
|
981
|
+
aliases:
|
|
982
|
+
fn: hexdag.builtin.nodes.FunctionNode
|
|
983
|
+
my_processor: myapp.nodes.ProcessorNode
|
|
984
|
+
nodes:
|
|
985
|
+
- kind: fn # Uses alias!
|
|
986
|
+
metadata:
|
|
987
|
+
name: parser
|
|
988
|
+
spec:
|
|
989
|
+
fn: json.loads
|
|
990
|
+
```
|
|
991
|
+
|
|
992
|
+
## Quick Reference Table
|
|
993
|
+
|
|
994
|
+
| Syntax | Location | Purpose |
|
|
995
|
+
|--------|----------|---------|
|
|
996
|
+
| `$input.field` | input_mapping | Access initial pipeline input |
|
|
997
|
+
| `$input` | input_mapping | Entire initial input |
|
|
998
|
+
| `{{node.field}}` | prompt_template | Jinja2 template reference |
|
|
999
|
+
| `${VAR}` | Any string value | Environment variable |
|
|
1000
|
+
| `${VAR:default}` | Any string value | Env var with default |
|
|
1001
|
+
| `node.path` | input_mapping | Dependency output extraction |
|
|
1002
|
+
"""
|
|
1003
|
+
|
|
1004
|
+
|
|
1005
|
+
@mcp.tool() # type: ignore[misc]
|
|
1006
|
+
def validate_yaml_pipeline_lenient(yaml_content: str) -> str:
|
|
1007
|
+
"""Validate YAML pipeline structure without requiring environment variables.
|
|
1008
|
+
|
|
1009
|
+
Use this for CI/CD validation where secrets aren't available.
|
|
1010
|
+
This validates structure only, without instantiating adapters.
|
|
1011
|
+
|
|
1012
|
+
Validates:
|
|
1013
|
+
- YAML syntax
|
|
1014
|
+
- Node structure and dependencies
|
|
1015
|
+
- Port configuration format
|
|
1016
|
+
- Manifest format (apiVersion, kind, metadata, spec)
|
|
1017
|
+
|
|
1018
|
+
Does NOT validate:
|
|
1019
|
+
- Environment variable values
|
|
1020
|
+
- Adapter instantiation
|
|
1021
|
+
- Module path resolution
|
|
1022
|
+
|
|
1023
|
+
Args
|
|
1024
|
+
----
|
|
1025
|
+
yaml_content: YAML pipeline configuration as a string
|
|
1026
|
+
|
|
1027
|
+
Returns
|
|
1028
|
+
-------
|
|
1029
|
+
JSON string with validation results (success/errors/warnings)
|
|
1030
|
+
|
|
1031
|
+
Examples
|
|
1032
|
+
--------
|
|
1033
|
+
>>> validate_yaml_pipeline_lenient(pipeline_yaml) # doctest: +SKIP
|
|
1034
|
+
{
|
|
1035
|
+
"valid": true,
|
|
1036
|
+
"message": "Pipeline structure is valid",
|
|
1037
|
+
"node_count": 3,
|
|
1038
|
+
"nodes": ["step1", "step2", "step3"],
|
|
1039
|
+
"warnings": []
|
|
1040
|
+
}
|
|
1041
|
+
"""
|
|
1042
|
+
try:
|
|
1043
|
+
# Parse YAML
|
|
1044
|
+
parsed = yaml.safe_load(yaml_content)
|
|
1045
|
+
|
|
1046
|
+
if not isinstance(parsed, dict):
|
|
1047
|
+
return json.dumps(
|
|
1048
|
+
{
|
|
1049
|
+
"valid": False,
|
|
1050
|
+
"error": "YAML must be a dictionary",
|
|
1051
|
+
"error_type": "ParseError",
|
|
1052
|
+
},
|
|
1053
|
+
indent=2,
|
|
1054
|
+
)
|
|
1055
|
+
|
|
1056
|
+
warnings: list[str] = []
|
|
1057
|
+
nodes: list[str] = []
|
|
1058
|
+
|
|
1059
|
+
# Check manifest format
|
|
1060
|
+
if "kind" not in parsed:
|
|
1061
|
+
return json.dumps(
|
|
1062
|
+
{
|
|
1063
|
+
"valid": False,
|
|
1064
|
+
"error": "Missing 'kind' field. Use declarative manifest format.",
|
|
1065
|
+
"error_type": "ManifestError",
|
|
1066
|
+
},
|
|
1067
|
+
indent=2,
|
|
1068
|
+
)
|
|
1069
|
+
|
|
1070
|
+
if "metadata" not in parsed:
|
|
1071
|
+
warnings.append("Missing 'metadata' field")
|
|
1072
|
+
|
|
1073
|
+
if "spec" not in parsed:
|
|
1074
|
+
return json.dumps(
|
|
1075
|
+
{
|
|
1076
|
+
"valid": False,
|
|
1077
|
+
"error": "Missing 'spec' field",
|
|
1078
|
+
"error_type": "ManifestError",
|
|
1079
|
+
},
|
|
1080
|
+
indent=2,
|
|
1081
|
+
)
|
|
1082
|
+
|
|
1083
|
+
spec = parsed.get("spec", {})
|
|
1084
|
+
|
|
1085
|
+
# Check nodes
|
|
1086
|
+
nodes_list = spec.get("nodes", [])
|
|
1087
|
+
if not nodes_list:
|
|
1088
|
+
warnings.append("No nodes defined in pipeline")
|
|
1089
|
+
|
|
1090
|
+
node_ids = set()
|
|
1091
|
+
for i, node in enumerate(nodes_list):
|
|
1092
|
+
if not isinstance(node, dict):
|
|
1093
|
+
return json.dumps(
|
|
1094
|
+
{
|
|
1095
|
+
"valid": False,
|
|
1096
|
+
"error": f"Node {i} is not a dictionary",
|
|
1097
|
+
"error_type": "NodeError",
|
|
1098
|
+
},
|
|
1099
|
+
indent=2,
|
|
1100
|
+
)
|
|
1101
|
+
|
|
1102
|
+
metadata = node.get("metadata", {})
|
|
1103
|
+
node_id = metadata.get("name")
|
|
1104
|
+
if not node_id:
|
|
1105
|
+
warnings.append(f"Node {i} missing 'metadata.name'")
|
|
1106
|
+
node_id = f"unnamed_{i}"
|
|
1107
|
+
|
|
1108
|
+
if node_id in node_ids:
|
|
1109
|
+
return json.dumps(
|
|
1110
|
+
{
|
|
1111
|
+
"valid": False,
|
|
1112
|
+
"error": f"Duplicate node name: {node_id}",
|
|
1113
|
+
"error_type": "NodeError",
|
|
1114
|
+
},
|
|
1115
|
+
indent=2,
|
|
1116
|
+
)
|
|
1117
|
+
|
|
1118
|
+
node_ids.add(node_id)
|
|
1119
|
+
nodes.append(node_id)
|
|
1120
|
+
|
|
1121
|
+
# Check dependencies reference valid nodes
|
|
1122
|
+
deps = node.get("dependencies", [])
|
|
1123
|
+
all_node_names = node_ids | {n.get("metadata", {}).get("name") for n in nodes_list}
|
|
1124
|
+
warnings.extend(
|
|
1125
|
+
f"Node '{node_id}' depends on '{dep}' which may not exist"
|
|
1126
|
+
for dep in deps
|
|
1127
|
+
if dep not in all_node_names
|
|
1128
|
+
)
|
|
1129
|
+
|
|
1130
|
+
# Check ports structure
|
|
1131
|
+
ports = spec.get("ports", {})
|
|
1132
|
+
for port_name, port_config in ports.items():
|
|
1133
|
+
if not isinstance(port_config, dict):
|
|
1134
|
+
warnings.append(f"Port '{port_name}' config is not a dictionary")
|
|
1135
|
+
elif "adapter" not in port_config and "name" not in port_config:
|
|
1136
|
+
warnings.append(f"Port '{port_name}' missing 'adapter' field")
|
|
1137
|
+
|
|
1138
|
+
return json.dumps(
|
|
1139
|
+
{
|
|
1140
|
+
"valid": True,
|
|
1141
|
+
"message": "Pipeline structure is valid",
|
|
1142
|
+
"node_count": len(nodes),
|
|
1143
|
+
"nodes": nodes,
|
|
1144
|
+
"ports": list(ports.keys()),
|
|
1145
|
+
"warnings": warnings,
|
|
1146
|
+
},
|
|
1147
|
+
indent=2,
|
|
1148
|
+
)
|
|
1149
|
+
|
|
1150
|
+
except yaml.YAMLError as e:
|
|
1151
|
+
return json.dumps(
|
|
1152
|
+
{
|
|
1153
|
+
"valid": False,
|
|
1154
|
+
"error": f"YAML parse error: {e}",
|
|
1155
|
+
"error_type": "ParseError",
|
|
1156
|
+
},
|
|
1157
|
+
indent=2,
|
|
1158
|
+
)
|
|
1159
|
+
except Exception as e:
|
|
1160
|
+
return json.dumps(
|
|
1161
|
+
{
|
|
1162
|
+
"valid": False,
|
|
1163
|
+
"error": str(e),
|
|
1164
|
+
"error_type": type(e).__name__,
|
|
1165
|
+
},
|
|
1166
|
+
indent=2,
|
|
1167
|
+
)
|
|
1168
|
+
|
|
1169
|
+
|
|
1170
|
+
# ============================================================================
|
|
1171
|
+
# Helper Functions
|
|
1172
|
+
# ============================================================================
|
|
1173
|
+
|
|
1174
|
+
|
|
1175
|
+
def _normalize_for_yaml(obj: Any) -> Any:
|
|
1176
|
+
"""Recursively convert enum values to strings for YAML serialization.
|
|
1177
|
+
|
|
1178
|
+
This ensures that enums are serialized using their .value attribute
|
|
1179
|
+
instead of their name, preventing validation errors when the YAML
|
|
1180
|
+
is loaded back.
|
|
1181
|
+
|
|
1182
|
+
Args
|
|
1183
|
+
----
|
|
1184
|
+
obj: Object to normalize (can be dict, list, enum, or primitive)
|
|
1185
|
+
|
|
1186
|
+
Returns
|
|
1187
|
+
-------
|
|
1188
|
+
Normalized object with enums converted to their string values
|
|
1189
|
+
|
|
1190
|
+
Examples
|
|
1191
|
+
--------
|
|
1192
|
+
>>> from enum import Enum # doctest: +SKIP
|
|
1193
|
+
>>> class Format(str, Enum): # doctest: +SKIP
|
|
1194
|
+
... MIXED = "mixed"
|
|
1195
|
+
>>> _normalize_for_yaml({"format": Format.MIXED}) # doctest: +SKIP
|
|
1196
|
+
{'format': 'mixed'}
|
|
1197
|
+
"""
|
|
1198
|
+
if isinstance(obj, Enum):
|
|
1199
|
+
return obj.value
|
|
1200
|
+
if isinstance(obj, dict):
|
|
1201
|
+
return {k: _normalize_for_yaml(v) for k, v in obj.items()}
|
|
1202
|
+
if isinstance(obj, list):
|
|
1203
|
+
return [_normalize_for_yaml(item) for item in obj]
|
|
1204
|
+
return obj
|
|
1205
|
+
|
|
1206
|
+
|
|
1207
|
+
# ============================================================================
|
|
1208
|
+
# YAML Pipeline Building Tools
|
|
1209
|
+
# ============================================================================
|
|
1210
|
+
|
|
1211
|
+
|
|
1212
|
+
@mcp.tool() # type: ignore[misc]
|
|
1213
|
+
def validate_yaml_pipeline(yaml_content: str) -> str:
|
|
1214
|
+
"""Validate a YAML pipeline configuration.
|
|
1215
|
+
|
|
1216
|
+
Args
|
|
1217
|
+
----
|
|
1218
|
+
yaml_content: YAML pipeline configuration as a string
|
|
1219
|
+
|
|
1220
|
+
Returns
|
|
1221
|
+
-------
|
|
1222
|
+
JSON string with validation results (success/errors)
|
|
1223
|
+
|
|
1224
|
+
Examples
|
|
1225
|
+
--------
|
|
1226
|
+
>>> validate_yaml_pipeline(pipeline_yaml) # doctest: +SKIP
|
|
1227
|
+
{
|
|
1228
|
+
"valid": true,
|
|
1229
|
+
"message": "Pipeline is valid",
|
|
1230
|
+
"node_count": 3,
|
|
1231
|
+
"nodes": ["step1", "step2", "step3"]
|
|
1232
|
+
}
|
|
1233
|
+
"""
|
|
1234
|
+
try:
|
|
1235
|
+
# Attempt to build the pipeline
|
|
1236
|
+
builder = YamlPipelineBuilder()
|
|
1237
|
+
graph, config = builder.build_from_yaml_string(yaml_content)
|
|
1238
|
+
|
|
1239
|
+
return json.dumps(
|
|
1240
|
+
{
|
|
1241
|
+
"valid": True,
|
|
1242
|
+
"message": "Pipeline is valid",
|
|
1243
|
+
"node_count": len(graph.nodes),
|
|
1244
|
+
"nodes": [node.name for node in graph.nodes.values()],
|
|
1245
|
+
"ports": list(config.ports.keys()) if config.ports else [],
|
|
1246
|
+
},
|
|
1247
|
+
indent=2,
|
|
1248
|
+
)
|
|
1249
|
+
except Exception as e:
|
|
1250
|
+
return json.dumps(
|
|
1251
|
+
{
|
|
1252
|
+
"valid": False,
|
|
1253
|
+
"error": str(e),
|
|
1254
|
+
"error_type": type(e).__name__,
|
|
1255
|
+
},
|
|
1256
|
+
indent=2,
|
|
1257
|
+
)
|
|
1258
|
+
|
|
1259
|
+
|
|
1260
|
+
@mcp.tool() # type: ignore[misc]
|
|
1261
|
+
def generate_pipeline_template(
|
|
1262
|
+
pipeline_name: str,
|
|
1263
|
+
description: str,
|
|
1264
|
+
node_types: list[str],
|
|
1265
|
+
) -> str:
|
|
1266
|
+
"""Generate a YAML pipeline template with specified node types.
|
|
1267
|
+
|
|
1268
|
+
Args
|
|
1269
|
+
----
|
|
1270
|
+
pipeline_name: Name for the pipeline
|
|
1271
|
+
description: Pipeline description
|
|
1272
|
+
node_types: List of node types to include (e.g., ["llm_node", "agent_node"])
|
|
1273
|
+
|
|
1274
|
+
Returns
|
|
1275
|
+
-------
|
|
1276
|
+
YAML pipeline template as a string
|
|
1277
|
+
|
|
1278
|
+
Examples
|
|
1279
|
+
--------
|
|
1280
|
+
>>> generate_pipeline_template( # doctest: +SKIP
|
|
1281
|
+
... "my-workflow",
|
|
1282
|
+
... "Example workflow",
|
|
1283
|
+
... ["llm_node", "function_node"]
|
|
1284
|
+
... )
|
|
1285
|
+
apiVersion: hexdag/v1
|
|
1286
|
+
kind: Pipeline
|
|
1287
|
+
metadata:
|
|
1288
|
+
name: my-workflow
|
|
1289
|
+
description: Example workflow
|
|
1290
|
+
spec:
|
|
1291
|
+
nodes:
|
|
1292
|
+
- kind: llm_node
|
|
1293
|
+
metadata:
|
|
1294
|
+
name: llm_1
|
|
1295
|
+
spec:
|
|
1296
|
+
prompt_template: "Your prompt here: {{input}}"
|
|
1297
|
+
output_key: result
|
|
1298
|
+
dependencies: []
|
|
1299
|
+
"""
|
|
1300
|
+
# Create basic pipeline structure using helper
|
|
1301
|
+
pipeline = _create_pipeline_base(pipeline_name, description)
|
|
1302
|
+
|
|
1303
|
+
# Add node templates
|
|
1304
|
+
for i, node_type in enumerate(node_types, 1):
|
|
1305
|
+
# Remove '_node' suffix if present for cleaner names
|
|
1306
|
+
node_name_base = node_type.replace("_node", "")
|
|
1307
|
+
|
|
1308
|
+
node_template = {
|
|
1309
|
+
"kind": node_type,
|
|
1310
|
+
"metadata": {
|
|
1311
|
+
"name": f"{node_name_base}_{i}",
|
|
1312
|
+
},
|
|
1313
|
+
"spec": _get_node_spec_template(node_type),
|
|
1314
|
+
"dependencies": [] if i == 1 else [f"{node_types[i - 2].replace('_node', '')}_{i - 1}"],
|
|
1315
|
+
}
|
|
1316
|
+
pipeline["spec"]["nodes"].append(node_template) # type: ignore[index]
|
|
1317
|
+
|
|
1318
|
+
# Normalize enums before serialization
|
|
1319
|
+
pipeline = _normalize_for_yaml(pipeline)
|
|
1320
|
+
return yaml.dump(pipeline, sort_keys=False, default_flow_style=False)
|
|
1321
|
+
|
|
1322
|
+
|
|
1323
|
+
def _get_node_spec_template(node_type: str) -> dict[str, Any]:
|
|
1324
|
+
"""Get a spec template for a given node type.
|
|
1325
|
+
|
|
1326
|
+
Args
|
|
1327
|
+
----
|
|
1328
|
+
node_type: Type of node (e.g., "llm_node", "agent_node")
|
|
1329
|
+
|
|
1330
|
+
Returns
|
|
1331
|
+
-------
|
|
1332
|
+
Dict with common spec fields for the node type
|
|
1333
|
+
"""
|
|
1334
|
+
templates = {
|
|
1335
|
+
"llm_node": {
|
|
1336
|
+
"prompt_template": "Your prompt here: {{input}}",
|
|
1337
|
+
"output_key": "result",
|
|
1338
|
+
},
|
|
1339
|
+
"agent_node": {
|
|
1340
|
+
"initial_prompt_template": "Task: {{task}}",
|
|
1341
|
+
"max_steps": 5,
|
|
1342
|
+
"output_key": "agent_result",
|
|
1343
|
+
"tools": [],
|
|
1344
|
+
},
|
|
1345
|
+
"function_node": {
|
|
1346
|
+
"fn": "your_module.your_function",
|
|
1347
|
+
"input_schema": {"param": "str"},
|
|
1348
|
+
"output_schema": {"result": "str"},
|
|
1349
|
+
},
|
|
1350
|
+
"conditional_node": {
|
|
1351
|
+
"condition": "{{input}} > 0",
|
|
1352
|
+
"true_path": [],
|
|
1353
|
+
"false_path": [],
|
|
1354
|
+
},
|
|
1355
|
+
"loop_node": {
|
|
1356
|
+
"loop_variable": "item",
|
|
1357
|
+
"items": "{{input_list}}",
|
|
1358
|
+
"body": [],
|
|
1359
|
+
},
|
|
1360
|
+
}
|
|
1361
|
+
|
|
1362
|
+
return templates.get(node_type, {}) # type: ignore[return-value]
|
|
1363
|
+
|
|
1364
|
+
|
|
1365
|
+
@mcp.tool() # type: ignore[misc]
|
|
1366
|
+
def build_yaml_pipeline_interactive(
|
|
1367
|
+
pipeline_name: str,
|
|
1368
|
+
description: str,
|
|
1369
|
+
nodes: list[dict[str, Any]],
|
|
1370
|
+
ports: dict[str, dict[str, Any]] | None = None,
|
|
1371
|
+
) -> str:
|
|
1372
|
+
"""Build a complete YAML pipeline with full specifications.
|
|
1373
|
+
|
|
1374
|
+
This is the recommended tool for building complete pipelines with LLM assistance.
|
|
1375
|
+
|
|
1376
|
+
Args
|
|
1377
|
+
----
|
|
1378
|
+
pipeline_name: Name for the pipeline
|
|
1379
|
+
description: Pipeline description
|
|
1380
|
+
nodes: List of node specifications with full config
|
|
1381
|
+
ports: Optional port configurations (llm, memory, database, etc.)
|
|
1382
|
+
|
|
1383
|
+
Returns
|
|
1384
|
+
-------
|
|
1385
|
+
Complete YAML pipeline configuration
|
|
1386
|
+
|
|
1387
|
+
Examples
|
|
1388
|
+
--------
|
|
1389
|
+
>>> build_yaml_pipeline_interactive( # doctest: +SKIP
|
|
1390
|
+
... "analysis-pipeline",
|
|
1391
|
+
... "Analyze documents",
|
|
1392
|
+
... nodes=[
|
|
1393
|
+
... {
|
|
1394
|
+
... "kind": "llm_node",
|
|
1395
|
+
... "name": "analyzer",
|
|
1396
|
+
... "spec": {"prompt_template": "Analyze: {{input}}"},
|
|
1397
|
+
... "dependencies": []
|
|
1398
|
+
... }
|
|
1399
|
+
... ],
|
|
1400
|
+
... ports={
|
|
1401
|
+
... "llm": {
|
|
1402
|
+
... "adapter": "openai",
|
|
1403
|
+
... "config": {"api_key": "${OPENAI_API_KEY}", "model": "gpt-4"}
|
|
1404
|
+
... }
|
|
1405
|
+
... }
|
|
1406
|
+
... )
|
|
1407
|
+
apiVersion: hexdag/v1
|
|
1408
|
+
kind: Pipeline
|
|
1409
|
+
metadata:
|
|
1410
|
+
name: analysis-pipeline
|
|
1411
|
+
description: Analyze documents
|
|
1412
|
+
spec:
|
|
1413
|
+
ports:
|
|
1414
|
+
llm:
|
|
1415
|
+
adapter: openai
|
|
1416
|
+
config:
|
|
1417
|
+
api_key: ${OPENAI_API_KEY}
|
|
1418
|
+
model: gpt-4
|
|
1419
|
+
nodes:
|
|
1420
|
+
- kind: llm_node
|
|
1421
|
+
metadata:
|
|
1422
|
+
name: analyzer
|
|
1423
|
+
spec:
|
|
1424
|
+
prompt_template: "Analyze: {{input}}"
|
|
1425
|
+
dependencies: []
|
|
1426
|
+
"""
|
|
1427
|
+
# Create pipeline using helper
|
|
1428
|
+
pipeline = _create_pipeline_base(pipeline_name, description, ports)
|
|
1429
|
+
|
|
1430
|
+
# Add nodes
|
|
1431
|
+
for node_def in nodes:
|
|
1432
|
+
node = {
|
|
1433
|
+
"kind": node_def["kind"],
|
|
1434
|
+
"metadata": {
|
|
1435
|
+
"name": node_def["name"],
|
|
1436
|
+
},
|
|
1437
|
+
"spec": node_def["spec"],
|
|
1438
|
+
"dependencies": node_def.get("dependencies", []),
|
|
1439
|
+
}
|
|
1440
|
+
pipeline["spec"]["nodes"].append(node) # type: ignore[index]
|
|
1441
|
+
|
|
1442
|
+
# Normalize enums before serialization
|
|
1443
|
+
pipeline = _normalize_for_yaml(pipeline)
|
|
1444
|
+
return yaml.dump(pipeline, sort_keys=False, default_flow_style=False)
|
|
1445
|
+
|
|
1446
|
+
|
|
1447
|
+
@mcp.tool() # type: ignore[misc]
|
|
1448
|
+
def create_environment_pipelines(
|
|
1449
|
+
pipeline_name: str,
|
|
1450
|
+
description: str,
|
|
1451
|
+
nodes: list[dict[str, Any]],
|
|
1452
|
+
dev_ports: dict[str, dict[str, Any]] | None = None,
|
|
1453
|
+
staging_ports: dict[str, dict[str, Any]] | None = None,
|
|
1454
|
+
prod_ports: dict[str, dict[str, Any]] | None = None,
|
|
1455
|
+
) -> str:
|
|
1456
|
+
"""Create dev, staging, and production YAML pipelines in one call.
|
|
1457
|
+
|
|
1458
|
+
This generates up to 3 YAML files with environment-specific port configurations:
|
|
1459
|
+
- dev: Uses mock adapters (no API keys)
|
|
1460
|
+
- staging: Uses real APIs with staging credentials
|
|
1461
|
+
- prod: Uses real APIs with production credentials
|
|
1462
|
+
|
|
1463
|
+
Args
|
|
1464
|
+
----
|
|
1465
|
+
pipeline_name: Base name for pipelines (will be suffixed with -dev, -staging, -prod)
|
|
1466
|
+
description: Pipeline description
|
|
1467
|
+
nodes: Node specifications (shared across all environments)
|
|
1468
|
+
dev_ports: Port config for dev (defaults to mock adapters if not provided)
|
|
1469
|
+
staging_ports: Port config for staging (optional)
|
|
1470
|
+
prod_ports: Port config for production (optional)
|
|
1471
|
+
|
|
1472
|
+
Returns
|
|
1473
|
+
-------
|
|
1474
|
+
JSON string with separate YAML content for each environment
|
|
1475
|
+
|
|
1476
|
+
Examples
|
|
1477
|
+
--------
|
|
1478
|
+
>>> create_environment_pipelines( # doctest: +SKIP
|
|
1479
|
+
... "research-agent",
|
|
1480
|
+
... "Research agent",
|
|
1481
|
+
... nodes=[{"kind": "macro_invocation", ...}],
|
|
1482
|
+
... prod_ports={"llm": {"adapter": "core:openai", ...}}
|
|
1483
|
+
... )
|
|
1484
|
+
{
|
|
1485
|
+
"dev": "apiVersion: hexdag/v1\\nkind: Pipeline...",
|
|
1486
|
+
"staging": "apiVersion: hexdag/v1\\nkind: Pipeline...",
|
|
1487
|
+
"prod": "apiVersion: hexdag/v1\\nkind: Pipeline..."
|
|
1488
|
+
}
|
|
1489
|
+
"""
|
|
1490
|
+
result = {}
|
|
1491
|
+
|
|
1492
|
+
# Default dev ports to mock adapters
|
|
1493
|
+
if dev_ports is None:
|
|
1494
|
+
dev_ports = {
|
|
1495
|
+
"llm": {
|
|
1496
|
+
"adapter": "hexdag.builtin.adapters.mock.MockLLMAdapter",
|
|
1497
|
+
"config": {
|
|
1498
|
+
"responses": [
|
|
1499
|
+
"I'll search for information. INVOKE_TOOL: search(query='...')",
|
|
1500
|
+
"Let me gather more details. INVOKE_TOOL: search(query='...')",
|
|
1501
|
+
"Based on research, here are my findings: [Mock comprehensive answer]",
|
|
1502
|
+
],
|
|
1503
|
+
"delay_seconds": 0.1,
|
|
1504
|
+
},
|
|
1505
|
+
},
|
|
1506
|
+
"tool_router": {
|
|
1507
|
+
"adapter": "hexdag.builtin.adapters.mock.MockToolRouterAdapter",
|
|
1508
|
+
"config": {"available_tools": ["search", "calculate"]},
|
|
1509
|
+
},
|
|
1510
|
+
}
|
|
1511
|
+
|
|
1512
|
+
# Helper to build pipeline for an environment
|
|
1513
|
+
def build_env_pipeline(env_name: str, env_suffix: str, env_ports: dict[str, Any]) -> str:
|
|
1514
|
+
pipeline = _create_pipeline_base(
|
|
1515
|
+
f"{pipeline_name}-{env_name}",
|
|
1516
|
+
f"{description} ({env_suffix})",
|
|
1517
|
+
env_ports,
|
|
1518
|
+
)
|
|
1519
|
+
for node_def in nodes:
|
|
1520
|
+
node = {
|
|
1521
|
+
"kind": node_def["kind"],
|
|
1522
|
+
"metadata": {"name": node_def["name"]},
|
|
1523
|
+
"spec": node_def["spec"],
|
|
1524
|
+
"dependencies": node_def.get("dependencies", []),
|
|
1525
|
+
}
|
|
1526
|
+
pipeline["spec"]["nodes"].append(node)
|
|
1527
|
+
pipeline = _normalize_for_yaml(pipeline)
|
|
1528
|
+
return yaml.dump(pipeline, sort_keys=False, default_flow_style=False)
|
|
1529
|
+
|
|
1530
|
+
# Build dev environment
|
|
1531
|
+
result["dev"] = build_env_pipeline("dev", "DEV - Mock Adapters", dev_ports)
|
|
1532
|
+
|
|
1533
|
+
# Build staging environment (if provided)
|
|
1534
|
+
if staging_ports:
|
|
1535
|
+
result["staging"] = build_env_pipeline("staging", "STAGING", staging_ports)
|
|
1536
|
+
|
|
1537
|
+
# Build production environment (if provided)
|
|
1538
|
+
if prod_ports:
|
|
1539
|
+
result["prod"] = build_env_pipeline("prod", "PRODUCTION", prod_ports)
|
|
1540
|
+
|
|
1541
|
+
return json.dumps(result, indent=2)
|
|
1542
|
+
|
|
1543
|
+
|
|
1544
|
+
@mcp.tool() # type: ignore[misc]
|
|
1545
|
+
def create_environment_pipelines_with_includes(
|
|
1546
|
+
pipeline_name: str,
|
|
1547
|
+
description: str,
|
|
1548
|
+
nodes: list[dict[str, Any]],
|
|
1549
|
+
dev_ports: dict[str, dict[str, Any]] | None = None,
|
|
1550
|
+
staging_ports: dict[str, dict[str, Any]] | None = None,
|
|
1551
|
+
prod_ports: dict[str, dict[str, Any]] | None = None,
|
|
1552
|
+
) -> str:
|
|
1553
|
+
"""Create base + environment-specific YAML files using the include pattern.
|
|
1554
|
+
|
|
1555
|
+
This generates a base YAML with shared nodes and separate environment configs
|
|
1556
|
+
that include the base file. This approach reduces duplication and makes it
|
|
1557
|
+
easier to maintain consistent logic across environments.
|
|
1558
|
+
|
|
1559
|
+
Generated files:
|
|
1560
|
+
- base.yaml: Shared node definitions
|
|
1561
|
+
- dev.yaml: Includes base + dev ports (mock adapters)
|
|
1562
|
+
- staging.yaml: Includes base + staging ports (optional)
|
|
1563
|
+
- prod.yaml: Includes base + prod ports (optional)
|
|
1564
|
+
|
|
1565
|
+
Args
|
|
1566
|
+
----
|
|
1567
|
+
pipeline_name: Base name for pipelines
|
|
1568
|
+
description: Pipeline description
|
|
1569
|
+
nodes: Node specifications (shared across all environments)
|
|
1570
|
+
dev_ports: Port config for dev (defaults to mock adapters if not provided)
|
|
1571
|
+
staging_ports: Port config for staging (optional)
|
|
1572
|
+
prod_ports: Port config for production (optional)
|
|
1573
|
+
|
|
1574
|
+
Returns
|
|
1575
|
+
-------
|
|
1576
|
+
JSON string with base YAML and environment-specific includes
|
|
1577
|
+
|
|
1578
|
+
Examples
|
|
1579
|
+
--------
|
|
1580
|
+
>>> create_environment_pipelines_with_includes( # doctest: +SKIP
|
|
1581
|
+
... "research-agent",
|
|
1582
|
+
... "Research agent",
|
|
1583
|
+
... nodes=[{"kind": "macro_invocation", ...}],
|
|
1584
|
+
... prod_ports={"llm": {"adapter": "core:openai", ...}}
|
|
1585
|
+
... )
|
|
1586
|
+
{
|
|
1587
|
+
"base": "apiVersion: hexdag/v1\\n...",
|
|
1588
|
+
"dev": "include: ./research_agent_base.yaml\\nports:\\n llm:\\n adapter: "
|
|
1589
|
+
"hexdag.builtin.adapters.mock.MockLLMAdapter",
|
|
1590
|
+
"prod": "include: ./research_agent_base.yaml\\nports:\\n llm:\\n adapter: ..."
|
|
1591
|
+
}
|
|
1592
|
+
"""
|
|
1593
|
+
result = {}
|
|
1594
|
+
|
|
1595
|
+
# Default dev ports to mock adapters
|
|
1596
|
+
if dev_ports is None:
|
|
1597
|
+
dev_ports = {
|
|
1598
|
+
"llm": {
|
|
1599
|
+
"adapter": "hexdag.builtin.adapters.mock.MockLLMAdapter",
|
|
1600
|
+
"config": {
|
|
1601
|
+
"responses": [
|
|
1602
|
+
"I'll search for information. INVOKE_TOOL: search(query='...')",
|
|
1603
|
+
"Let me gather more details. INVOKE_TOOL: search(query='...')",
|
|
1604
|
+
"Based on research, here are my findings: [Mock comprehensive answer]",
|
|
1605
|
+
],
|
|
1606
|
+
"delay_seconds": 0.1,
|
|
1607
|
+
},
|
|
1608
|
+
},
|
|
1609
|
+
"tool_router": {
|
|
1610
|
+
"adapter": "hexdag.builtin.adapters.mock.MockToolRouterAdapter",
|
|
1611
|
+
"config": {"available_tools": ["search", "calculate"]},
|
|
1612
|
+
},
|
|
1613
|
+
}
|
|
1614
|
+
|
|
1615
|
+
# Build base YAML (nodes only, no ports)
|
|
1616
|
+
base_pipeline = _create_pipeline_base(
|
|
1617
|
+
f"{pipeline_name}-base",
|
|
1618
|
+
f"{description} (Base Configuration)",
|
|
1619
|
+
)
|
|
1620
|
+
for node_def in nodes:
|
|
1621
|
+
node = {
|
|
1622
|
+
"kind": node_def["kind"],
|
|
1623
|
+
"metadata": {"name": node_def["name"]},
|
|
1624
|
+
"spec": node_def["spec"],
|
|
1625
|
+
"dependencies": node_def.get("dependencies", []),
|
|
1626
|
+
}
|
|
1627
|
+
base_pipeline["spec"]["nodes"].append(node)
|
|
1628
|
+
base_pipeline = _normalize_for_yaml(base_pipeline)
|
|
1629
|
+
result["base"] = yaml.dump(base_pipeline, sort_keys=False, default_flow_style=False)
|
|
1630
|
+
|
|
1631
|
+
# Helper to build environment include file
|
|
1632
|
+
def build_env_include(env_name: str, env_suffix: str, env_ports: dict[str, Any]) -> str:
|
|
1633
|
+
env_config = {
|
|
1634
|
+
"include": f"./{pipeline_name}_base.yaml",
|
|
1635
|
+
"metadata": {
|
|
1636
|
+
"name": f"{pipeline_name}-{env_name}",
|
|
1637
|
+
"description": f"{description} ({env_suffix})",
|
|
1638
|
+
},
|
|
1639
|
+
"ports": env_ports,
|
|
1640
|
+
}
|
|
1641
|
+
env_config = _normalize_for_yaml(env_config)
|
|
1642
|
+
return yaml.dump(env_config, sort_keys=False, default_flow_style=False)
|
|
1643
|
+
|
|
1644
|
+
# Build environment include files
|
|
1645
|
+
result["dev"] = build_env_include("dev", "DEV - Mock Adapters", dev_ports)
|
|
1646
|
+
|
|
1647
|
+
if staging_ports:
|
|
1648
|
+
result["staging"] = build_env_include("staging", "STAGING", staging_ports)
|
|
1649
|
+
|
|
1650
|
+
if prod_ports:
|
|
1651
|
+
result["prod"] = build_env_include("prod", "PRODUCTION", prod_ports)
|
|
1652
|
+
|
|
1653
|
+
return json.dumps(result, indent=2)
|
|
1654
|
+
|
|
1655
|
+
|
|
1656
|
+
@mcp.tool() # type: ignore[misc]
|
|
1657
|
+
def explain_yaml_structure() -> str:
|
|
1658
|
+
"""Explain the structure of hexDAG YAML pipelines.
|
|
1659
|
+
|
|
1660
|
+
Returns
|
|
1661
|
+
-------
|
|
1662
|
+
Detailed explanation of YAML pipeline structure with examples
|
|
1663
|
+
"""
|
|
1664
|
+
return """# hexDAG YAML Pipeline Structure
|
|
1665
|
+
|
|
1666
|
+
## Basic Structure
|
|
1667
|
+
|
|
1668
|
+
```yaml
|
|
1669
|
+
apiVersion: hexdag/v1
|
|
1670
|
+
kind: Pipeline
|
|
1671
|
+
metadata:
|
|
1672
|
+
name: pipeline-name
|
|
1673
|
+
description: What this pipeline does
|
|
1674
|
+
spec:
|
|
1675
|
+
ports: # Optional: Configure adapters
|
|
1676
|
+
llm:
|
|
1677
|
+
adapter: openai
|
|
1678
|
+
config:
|
|
1679
|
+
api_key: ${OPENAI_API_KEY}
|
|
1680
|
+
model: gpt-4
|
|
1681
|
+
nodes: # Required: Pipeline nodes
|
|
1682
|
+
- kind: llm_node
|
|
1683
|
+
metadata:
|
|
1684
|
+
name: node_name
|
|
1685
|
+
spec:
|
|
1686
|
+
prompt_template: "Your prompt: {{input}}"
|
|
1687
|
+
dependencies: []
|
|
1688
|
+
```
|
|
1689
|
+
|
|
1690
|
+
## Key Concepts
|
|
1691
|
+
|
|
1692
|
+
1. **apiVersion**: Always "hexdag/v1"
|
|
1693
|
+
2. **kind**: Always "Pipeline" (or "Macro" for macro definitions)
|
|
1694
|
+
3. **metadata**: Pipeline name and description
|
|
1695
|
+
4. **spec**: Pipeline specification
|
|
1696
|
+
- **ports**: Adapter configurations (llm, memory, database, secret)
|
|
1697
|
+
- **nodes**: List of processing nodes
|
|
1698
|
+
|
|
1699
|
+
## Node Structure
|
|
1700
|
+
|
|
1701
|
+
Each node has:
|
|
1702
|
+
- **kind**: Node type (llm_node, agent_node, function_node, etc.)
|
|
1703
|
+
- **metadata.name**: Unique identifier for the node
|
|
1704
|
+
- **spec**: Node-specific configuration
|
|
1705
|
+
- **dependencies**: List of node names this node depends on
|
|
1706
|
+
|
|
1707
|
+
## Available Node Types
|
|
1708
|
+
|
|
1709
|
+
- **llm_node**: LLM interactions with prompt templates
|
|
1710
|
+
- **agent_node**: ReAct agents with tool access
|
|
1711
|
+
- **function_node**: Execute Python functions
|
|
1712
|
+
- **conditional_node**: Conditional execution paths
|
|
1713
|
+
- **loop_node**: Iterative processing
|
|
1714
|
+
|
|
1715
|
+
## Templating
|
|
1716
|
+
|
|
1717
|
+
Use Jinja2 syntax for dynamic values:
|
|
1718
|
+
- `{{variable}}` - Reference node outputs or inputs
|
|
1719
|
+
- `{{node_name.output_key}}` - Reference specific node outputs
|
|
1720
|
+
- `${ENV_VAR}` - Environment variables (resolved at build or runtime)
|
|
1721
|
+
|
|
1722
|
+
## Port Configuration
|
|
1723
|
+
|
|
1724
|
+
```yaml
|
|
1725
|
+
ports:
|
|
1726
|
+
llm:
|
|
1727
|
+
adapter: openai
|
|
1728
|
+
config:
|
|
1729
|
+
api_key: ${OPENAI_API_KEY}
|
|
1730
|
+
model: gpt-4
|
|
1731
|
+
memory:
|
|
1732
|
+
adapter: in_memory
|
|
1733
|
+
database:
|
|
1734
|
+
adapter: sqlite
|
|
1735
|
+
config:
|
|
1736
|
+
database_path: ./data.db
|
|
1737
|
+
```
|
|
1738
|
+
|
|
1739
|
+
## Dependencies
|
|
1740
|
+
|
|
1741
|
+
Dependencies define execution order:
|
|
1742
|
+
```yaml
|
|
1743
|
+
nodes:
|
|
1744
|
+
- metadata:
|
|
1745
|
+
name: step1
|
|
1746
|
+
dependencies: [] # Runs first
|
|
1747
|
+
|
|
1748
|
+
- metadata:
|
|
1749
|
+
name: step2
|
|
1750
|
+
dependencies: [step1] # Runs after step1
|
|
1751
|
+
|
|
1752
|
+
- metadata:
|
|
1753
|
+
name: step3
|
|
1754
|
+
dependencies: [step1, step2] # Runs after both
|
|
1755
|
+
```
|
|
1756
|
+
|
|
1757
|
+
## Secret Handling
|
|
1758
|
+
|
|
1759
|
+
Secret-like environment variables are deferred to runtime:
|
|
1760
|
+
- `*_API_KEY`, `*_SECRET`, `*_TOKEN`, `*_PASSWORD`
|
|
1761
|
+
- Allows building pipelines without secrets present
|
|
1762
|
+
- Runtime injection via SecretPort → Memory
|
|
1763
|
+
|
|
1764
|
+
## Best Practices
|
|
1765
|
+
|
|
1766
|
+
1. Use descriptive node names
|
|
1767
|
+
2. Add comprehensive descriptions
|
|
1768
|
+
3. Leverage environment variables for secrets
|
|
1769
|
+
4. Keep pipelines modular and reusable
|
|
1770
|
+
5. Validate before execution using validate_yaml_pipeline()
|
|
1771
|
+
6. Use macro_invocation for reusable patterns
|
|
1772
|
+
"""
|
|
1773
|
+
|
|
1774
|
+
|
|
1775
|
+
@mcp.tool() # type: ignore[misc]
|
|
1776
|
+
def get_custom_adapter_guide() -> str:
|
|
1777
|
+
"""Get a comprehensive guide for creating custom adapters.
|
|
1778
|
+
|
|
1779
|
+
Returns documentation on:
|
|
1780
|
+
- Creating adapters with the @adapter decorator
|
|
1781
|
+
- Secret handling with the secrets parameter
|
|
1782
|
+
- Using custom adapters in YAML pipelines
|
|
1783
|
+
- Testing patterns for adapters
|
|
1784
|
+
|
|
1785
|
+
Returns
|
|
1786
|
+
-------
|
|
1787
|
+
Detailed guide for creating custom adapters
|
|
1788
|
+
|
|
1789
|
+
Examples
|
|
1790
|
+
--------
|
|
1791
|
+
>>> get_custom_adapter_guide() # doctest: +SKIP
|
|
1792
|
+
# Creating Custom Adapters in hexDAG
|
|
1793
|
+
...
|
|
1794
|
+
"""
|
|
1795
|
+
# Try to load auto-generated documentation first
|
|
1796
|
+
generated = _load_generated_doc("adapter_guide.md")
|
|
1797
|
+
if generated:
|
|
1798
|
+
return generated
|
|
1799
|
+
|
|
1800
|
+
# Fallback to static documentation
|
|
1801
|
+
return '''# Creating Custom Adapters in hexDAG
|
|
1802
|
+
|
|
1803
|
+
## Overview
|
|
1804
|
+
|
|
1805
|
+
hexDAG uses a decorator-based pattern for creating adapters. Adapters implement
|
|
1806
|
+
"ports" (interfaces) that connect your pipelines to external services like LLMs,
|
|
1807
|
+
databases, and APIs.
|
|
1808
|
+
|
|
1809
|
+
## Quick Start
|
|
1810
|
+
|
|
1811
|
+
### Simple Adapter (No Secrets)
|
|
1812
|
+
|
|
1813
|
+
```python
|
|
1814
|
+
from hexdag.core.registry import adapter
|
|
1815
|
+
|
|
1816
|
+
@adapter("cache", name="memory_cache")
|
|
1817
|
+
class MemoryCacheAdapter:
|
|
1818
|
+
"""Simple in-memory cache adapter."""
|
|
1819
|
+
|
|
1820
|
+
def __init__(self, max_size: int = 100, ttl: int = 3600):
|
|
1821
|
+
self.cache = {}
|
|
1822
|
+
self.max_size = max_size
|
|
1823
|
+
self.ttl = ttl
|
|
1824
|
+
|
|
1825
|
+
async def aget(self, key: str):
|
|
1826
|
+
return self.cache.get(key)
|
|
1827
|
+
|
|
1828
|
+
async def aset(self, key: str, value: any):
|
|
1829
|
+
self.cache[key] = value
|
|
1830
|
+
```
|
|
1831
|
+
|
|
1832
|
+
### Adapter with Secrets
|
|
1833
|
+
|
|
1834
|
+
```python
|
|
1835
|
+
from hexdag.core.registry import adapter
|
|
1836
|
+
|
|
1837
|
+
@adapter("llm", name="openai", secrets={"api_key": "OPENAI_API_KEY"})
|
|
1838
|
+
class OpenAIAdapter:
|
|
1839
|
+
"""OpenAI LLM adapter with automatic secret resolution."""
|
|
1840
|
+
|
|
1841
|
+
def __init__(
|
|
1842
|
+
self,
|
|
1843
|
+
api_key: str, # Auto-resolved from OPENAI_API_KEY env var
|
|
1844
|
+
model: str = "gpt-4",
|
|
1845
|
+
temperature: float = 0.7
|
|
1846
|
+
):
|
|
1847
|
+
self.api_key = api_key
|
|
1848
|
+
self.model = model
|
|
1849
|
+
self.temperature = temperature
|
|
1850
|
+
|
|
1851
|
+
async def aresponse(self, messages: list) -> str:
|
|
1852
|
+
# Your OpenAI API implementation
|
|
1853
|
+
...
|
|
1854
|
+
```
|
|
1855
|
+
|
|
1856
|
+
### Adapter with Multiple Secrets
|
|
1857
|
+
|
|
1858
|
+
```python
|
|
1859
|
+
@adapter(
|
|
1860
|
+
"database",
|
|
1861
|
+
name="postgres",
|
|
1862
|
+
secrets={
|
|
1863
|
+
"username": "DB_USERNAME",
|
|
1864
|
+
"password": "DB_PASSWORD"
|
|
1865
|
+
}
|
|
1866
|
+
)
|
|
1867
|
+
class PostgresAdapter:
|
|
1868
|
+
def __init__(
|
|
1869
|
+
self,
|
|
1870
|
+
username: str, # From DB_USERNAME
|
|
1871
|
+
password: str, # From DB_PASSWORD
|
|
1872
|
+
host: str = "localhost",
|
|
1873
|
+
port: int = 5432,
|
|
1874
|
+
database: str = "mydb"
|
|
1875
|
+
):
|
|
1876
|
+
self.connection_string = (
|
|
1877
|
+
f"postgresql://{username}:{password}@{host}:{port}/{database}"
|
|
1878
|
+
)
|
|
1879
|
+
```
|
|
1880
|
+
|
|
1881
|
+
## The @adapter Decorator
|
|
1882
|
+
|
|
1883
|
+
### Parameters
|
|
1884
|
+
|
|
1885
|
+
| Parameter | Type | Description |
|
|
1886
|
+
|-----------|------|-------------|
|
|
1887
|
+
| `port_type` | str | Port this adapter implements ("llm", "memory", "database", etc.) |
|
|
1888
|
+
| `name` | str | Unique adapter name for registration |
|
|
1889
|
+
| `namespace` | str | Namespace (default: "plugin") |
|
|
1890
|
+
| `secrets` | dict | Map of param names to env var names |
|
|
1891
|
+
|
|
1892
|
+
### Secret Resolution Order
|
|
1893
|
+
|
|
1894
|
+
Secrets are resolved in this order:
|
|
1895
|
+
1. **Explicit kwargs** - Values passed directly to `__init__`
|
|
1896
|
+
2. **Environment variables** - From the `secrets` mapping
|
|
1897
|
+
3. **Memory port** - From orchestrator memory (with `secret:` prefix)
|
|
1898
|
+
4. **Error** - If required and no default
|
|
1899
|
+
|
|
1900
|
+
## Using Custom Adapters in YAML
|
|
1901
|
+
|
|
1902
|
+
### Register Your Adapter
|
|
1903
|
+
|
|
1904
|
+
Your adapter module must be importable. Either:
|
|
1905
|
+
- Install as a package
|
|
1906
|
+
- Add to `PYTHONPATH`
|
|
1907
|
+
- Place in `hexdag_plugins/` directory
|
|
1908
|
+
|
|
1909
|
+
### Reference in YAML Pipeline
|
|
1910
|
+
|
|
1911
|
+
```yaml
|
|
1912
|
+
apiVersion: hexdag/v1
|
|
1913
|
+
kind: Pipeline
|
|
1914
|
+
metadata:
|
|
1915
|
+
name: my-pipeline
|
|
1916
|
+
spec:
|
|
1917
|
+
ports:
|
|
1918
|
+
llm:
|
|
1919
|
+
adapter: mycompany.adapters.CustomLLMAdapter
|
|
1920
|
+
config:
|
|
1921
|
+
api_key: ${MY_API_KEY}
|
|
1922
|
+
model: gpt-4-turbo
|
|
1923
|
+
temperature: 0.5
|
|
1924
|
+
|
|
1925
|
+
database:
|
|
1926
|
+
adapter: mycompany.adapters.PostgresAdapter
|
|
1927
|
+
config:
|
|
1928
|
+
host: ${DB_HOST}
|
|
1929
|
+
port: 5432
|
|
1930
|
+
database: production
|
|
1931
|
+
|
|
1932
|
+
nodes:
|
|
1933
|
+
- kind: llm_node
|
|
1934
|
+
metadata:
|
|
1935
|
+
name: analyzer
|
|
1936
|
+
spec:
|
|
1937
|
+
prompt_template: "Analyze: {{input}}"
|
|
1938
|
+
dependencies: []
|
|
1939
|
+
```
|
|
1940
|
+
|
|
1941
|
+
### Using Aliases for Cleaner YAML
|
|
1942
|
+
|
|
1943
|
+
```yaml
|
|
1944
|
+
spec:
|
|
1945
|
+
aliases:
|
|
1946
|
+
my_llm: mycompany.adapters.CustomLLMAdapter
|
|
1947
|
+
my_db: mycompany.adapters.PostgresAdapter
|
|
1948
|
+
|
|
1949
|
+
ports:
|
|
1950
|
+
llm:
|
|
1951
|
+
adapter: my_llm # Uses alias!
|
|
1952
|
+
config:
|
|
1953
|
+
model: gpt-4
|
|
1954
|
+
```
|
|
1955
|
+
|
|
1956
|
+
## Plugin Directory Structure
|
|
1957
|
+
|
|
1958
|
+
For organized plugin development:
|
|
1959
|
+
|
|
1960
|
+
```
|
|
1961
|
+
hexdag_plugins/
|
|
1962
|
+
└── my_adapter/
|
|
1963
|
+
├── __init__.py
|
|
1964
|
+
├── my_adapter.py # Adapter implementation
|
|
1965
|
+
├── pyproject.toml # Dependencies
|
|
1966
|
+
└── tests/
|
|
1967
|
+
└── test_my_adapter.py
|
|
1968
|
+
```
|
|
1969
|
+
|
|
1970
|
+
### pyproject.toml for Plugin
|
|
1971
|
+
|
|
1972
|
+
```toml
|
|
1973
|
+
[project]
|
|
1974
|
+
name = "hexdag-my-adapter"
|
|
1975
|
+
version = "0.1.0"
|
|
1976
|
+
dependencies = [
|
|
1977
|
+
"hexdag>=0.2.0",
|
|
1978
|
+
"httpx>=0.25.0", # Your adapter dependencies
|
|
1979
|
+
]
|
|
1980
|
+
|
|
1981
|
+
[tool.hexdag]
|
|
1982
|
+
plugins = ["hexdag_plugins.my_adapter"]
|
|
1983
|
+
```
|
|
1984
|
+
|
|
1985
|
+
## Testing Your Adapter
|
|
1986
|
+
|
|
1987
|
+
### Unit Test Pattern
|
|
1988
|
+
|
|
1989
|
+
```python
|
|
1990
|
+
import pytest
|
|
1991
|
+
from mycompany.adapters import CustomLLMAdapter
|
|
1992
|
+
|
|
1993
|
+
@pytest.fixture
|
|
1994
|
+
def adapter():
|
|
1995
|
+
return CustomLLMAdapter(
|
|
1996
|
+
api_key="test-key",
|
|
1997
|
+
model="gpt-4",
|
|
1998
|
+
temperature=0.5
|
|
1999
|
+
)
|
|
2000
|
+
|
|
2001
|
+
@pytest.mark.asyncio
|
|
2002
|
+
async def test_adapter_response(adapter, mocker):
|
|
2003
|
+
# Mock external API call
|
|
2004
|
+
mock_response = mocker.patch.object(
|
|
2005
|
+
adapter, "_call_api",
|
|
2006
|
+
return_value="Test response"
|
|
2007
|
+
)
|
|
2008
|
+
|
|
2009
|
+
result = await adapter.aresponse([{"role": "user", "content": "Hello"}])
|
|
2010
|
+
|
|
2011
|
+
assert result == "Test response"
|
|
2012
|
+
mock_response.assert_called_once()
|
|
2013
|
+
```
|
|
2014
|
+
|
|
2015
|
+
### Integration Test with Mock
|
|
2016
|
+
|
|
2017
|
+
```python
|
|
2018
|
+
from hexdag.builtin.adapters.mock import MockLLM
|
|
2019
|
+
|
|
2020
|
+
def test_pipeline_with_mock():
|
|
2021
|
+
"""Test pipeline logic without real API calls."""
|
|
2022
|
+
mock_llm = MockLLM(responses=["Analysis complete", "Summary done"])
|
|
2023
|
+
|
|
2024
|
+
# Use mock_llm in your pipeline tests
|
|
2025
|
+
```
|
|
2026
|
+
|
|
2027
|
+
## Common Port Types
|
|
2028
|
+
|
|
2029
|
+
| Port | Purpose | Key Methods |
|
|
2030
|
+
|------|---------|-------------|
|
|
2031
|
+
| `llm` | Language models | `aresponse(messages) -> str` |
|
|
2032
|
+
| `memory` | Key-value storage | `aget(key)`, `aset(key, value)` |
|
|
2033
|
+
| `database` | SQL/NoSQL databases | `aexecute_query(sql, params)` |
|
|
2034
|
+
| `secret` | Secret management | `aget_secret(name)` |
|
|
2035
|
+
| `tool_router` | Tool execution | `acall_tool(name, args)` |
|
|
2036
|
+
| `observer_manager` | Event observation | `notify(event)` |
|
|
2037
|
+
|
|
2038
|
+
## Best Practices
|
|
2039
|
+
|
|
2040
|
+
1. **Async First**: Use `async def` for I/O operations
|
|
2041
|
+
2. **Type Hints**: Add type annotations for better tooling
|
|
2042
|
+
3. **Docstrings**: Document your adapter's purpose and config
|
|
2043
|
+
4. **Error Handling**: Wrap external calls in try/except
|
|
2044
|
+
5. **Logging**: Use `hexdag.core.logging.get_logger(__name__)`
|
|
2045
|
+
6. **Secrets**: Never hardcode secrets; use the `secrets` parameter
|
|
2046
|
+
|
|
2047
|
+
## CLI Commands for Plugin Development
|
|
2048
|
+
|
|
2049
|
+
```bash
|
|
2050
|
+
# Create a new plugin
|
|
2051
|
+
hexdag plugin new my_adapter --port llm
|
|
2052
|
+
|
|
2053
|
+
# Lint and test
|
|
2054
|
+
hexdag plugin lint my_adapter
|
|
2055
|
+
hexdag plugin test my_adapter
|
|
2056
|
+
|
|
2057
|
+
# Install dependencies
|
|
2058
|
+
hexdag plugin install my_adapter
|
|
2059
|
+
```
|
|
2060
|
+
'''
|
|
2061
|
+
|
|
2062
|
+
|
|
2063
|
+
@mcp.tool() # type: ignore[misc]
|
|
2064
|
+
def get_custom_node_guide() -> str:
|
|
2065
|
+
"""Get a comprehensive guide for creating custom nodes.
|
|
2066
|
+
|
|
2067
|
+
Returns documentation on:
|
|
2068
|
+
- Creating nodes with the @node decorator
|
|
2069
|
+
- Node factory pattern
|
|
2070
|
+
- Input/output schemas
|
|
2071
|
+
- Using custom nodes in YAML pipelines
|
|
2072
|
+
|
|
2073
|
+
Returns
|
|
2074
|
+
-------
|
|
2075
|
+
Detailed guide for creating custom nodes
|
|
2076
|
+
|
|
2077
|
+
Examples
|
|
2078
|
+
--------
|
|
2079
|
+
>>> get_custom_node_guide() # doctest: +SKIP
|
|
2080
|
+
# Creating Custom Nodes in hexDAG
|
|
2081
|
+
...
|
|
2082
|
+
"""
|
|
2083
|
+
# Try to load auto-generated documentation first
|
|
2084
|
+
generated = _load_generated_doc("node_guide.md")
|
|
2085
|
+
if generated:
|
|
2086
|
+
return generated
|
|
2087
|
+
|
|
2088
|
+
# Fallback to static documentation
|
|
2089
|
+
return '''# Creating Custom Nodes in hexDAG
|
|
2090
|
+
|
|
2091
|
+
## Overview
|
|
2092
|
+
|
|
2093
|
+
Nodes are the building blocks of hexDAG pipelines. Each node performs a specific
|
|
2094
|
+
task and can be connected to other nodes via dependencies.
|
|
2095
|
+
|
|
2096
|
+
## Quick Start
|
|
2097
|
+
|
|
2098
|
+
### Simple Function Node
|
|
2099
|
+
|
|
2100
|
+
The easiest way to create a custom node is using FunctionNode with your own function:
|
|
2101
|
+
|
|
2102
|
+
```yaml
|
|
2103
|
+
# In YAML - reference any Python function by module path
|
|
2104
|
+
- kind: function_node
|
|
2105
|
+
metadata:
|
|
2106
|
+
name: my_processor
|
|
2107
|
+
spec:
|
|
2108
|
+
fn: mycompany.processors.process_data
|
|
2109
|
+
dependencies: []
|
|
2110
|
+
```
|
|
2111
|
+
|
|
2112
|
+
```python
|
|
2113
|
+
# mycompany/processors.py
|
|
2114
|
+
def process_data(input_data: dict) -> dict:
|
|
2115
|
+
"""Your processing logic."""
|
|
2116
|
+
return {"result": input_data["value"] * 2}
|
|
2117
|
+
```
|
|
2118
|
+
|
|
2119
|
+
### Custom Node Class
|
|
2120
|
+
|
|
2121
|
+
For more complex logic, create a node class:
|
|
2122
|
+
|
|
2123
|
+
```python
|
|
2124
|
+
from hexdag.core.registry import node
|
|
2125
|
+
from hexdag.builtin.nodes import BaseNodeFactory
|
|
2126
|
+
from hexdag.core.domain.dag import NodeSpec
|
|
2127
|
+
|
|
2128
|
+
@node(name="custom_processor", namespace="plugin")
|
|
2129
|
+
class CustomProcessorNode(BaseNodeFactory):
|
|
2130
|
+
"""Custom node for specialized processing."""
|
|
2131
|
+
|
|
2132
|
+
def __init__(self):
|
|
2133
|
+
super().__init__()
|
|
2134
|
+
|
|
2135
|
+
def __call__(
|
|
2136
|
+
self,
|
|
2137
|
+
name: str,
|
|
2138
|
+
threshold: float = 0.5,
|
|
2139
|
+
mode: str = "standard",
|
|
2140
|
+
**kwargs
|
|
2141
|
+
) -> NodeSpec:
|
|
2142
|
+
async def process_fn(input_data: dict) -> dict:
|
|
2143
|
+
# Your async processing logic
|
|
2144
|
+
if input_data.get("score", 0) > threshold:
|
|
2145
|
+
return {"status": "pass", "mode": mode}
|
|
2146
|
+
return {"status": "fail", "mode": mode}
|
|
2147
|
+
|
|
2148
|
+
return NodeSpec(
|
|
2149
|
+
name=name,
|
|
2150
|
+
fn=process_fn,
|
|
2151
|
+
deps=frozenset(kwargs.get("deps", [])),
|
|
2152
|
+
params={"threshold": threshold, "mode": mode},
|
|
2153
|
+
)
|
|
2154
|
+
```
|
|
2155
|
+
|
|
2156
|
+
## Using Custom Nodes in YAML
|
|
2157
|
+
|
|
2158
|
+
### With Full Module Path
|
|
2159
|
+
|
|
2160
|
+
```yaml
|
|
2161
|
+
apiVersion: hexdag/v1
|
|
2162
|
+
kind: Pipeline
|
|
2163
|
+
metadata:
|
|
2164
|
+
name: custom-pipeline
|
|
2165
|
+
spec:
|
|
2166
|
+
nodes:
|
|
2167
|
+
- kind: mycompany.nodes.CustomProcessorNode
|
|
2168
|
+
metadata:
|
|
2169
|
+
name: processor
|
|
2170
|
+
spec:
|
|
2171
|
+
threshold: 0.7
|
|
2172
|
+
mode: strict
|
|
2173
|
+
dependencies: []
|
|
2174
|
+
```
|
|
2175
|
+
|
|
2176
|
+
### With Aliases
|
|
2177
|
+
|
|
2178
|
+
```yaml
|
|
2179
|
+
spec:
|
|
2180
|
+
aliases:
|
|
2181
|
+
processor: mycompany.nodes.CustomProcessorNode
|
|
2182
|
+
|
|
2183
|
+
nodes:
|
|
2184
|
+
- kind: processor # Uses alias!
|
|
2185
|
+
metadata:
|
|
2186
|
+
name: my_processor
|
|
2187
|
+
spec:
|
|
2188
|
+
threshold: 0.7
|
|
2189
|
+
dependencies: []
|
|
2190
|
+
```
|
|
2191
|
+
|
|
2192
|
+
## Node Factory Pattern
|
|
2193
|
+
|
|
2194
|
+
hexDAG nodes use the factory pattern:
|
|
2195
|
+
|
|
2196
|
+
```python
|
|
2197
|
+
class MyNode(BaseNodeFactory):
|
|
2198
|
+
def __call__(self, name: str, **params) -> NodeSpec:
|
|
2199
|
+
# Factory method creates NodeSpec when called
|
|
2200
|
+
return NodeSpec(
|
|
2201
|
+
name=name,
|
|
2202
|
+
fn=self._create_function(**params),
|
|
2203
|
+
deps=frozenset(params.get("deps", [])),
|
|
2204
|
+
params=params,
|
|
2205
|
+
)
|
|
2206
|
+
|
|
2207
|
+
def _create_function(self, **params):
|
|
2208
|
+
async def node_function(input_data):
|
|
2209
|
+
# Actual processing logic
|
|
2210
|
+
return {"result": "processed"}
|
|
2211
|
+
return node_function
|
|
2212
|
+
```
|
|
2213
|
+
|
|
2214
|
+
## Input/Output Schemas
|
|
2215
|
+
|
|
2216
|
+
Define schemas for type validation:
|
|
2217
|
+
|
|
2218
|
+
```python
|
|
2219
|
+
from pydantic import BaseModel
|
|
2220
|
+
|
|
2221
|
+
class ProcessorInput(BaseModel):
|
|
2222
|
+
text: str
|
|
2223
|
+
options: dict = {}
|
|
2224
|
+
|
|
2225
|
+
class ProcessorOutput(BaseModel):
|
|
2226
|
+
result: str
|
|
2227
|
+
confidence: float
|
|
2228
|
+
|
|
2229
|
+
@node(name="typed_processor", namespace="plugin")
|
|
2230
|
+
class TypedProcessorNode(BaseNodeFactory):
|
|
2231
|
+
def __call__(
|
|
2232
|
+
self,
|
|
2233
|
+
name: str,
|
|
2234
|
+
**kwargs
|
|
2235
|
+
) -> NodeSpec:
|
|
2236
|
+
async def process_fn(input_data: ProcessorInput) -> ProcessorOutput:
|
|
2237
|
+
return ProcessorOutput(
|
|
2238
|
+
result=input_data.text.upper(),
|
|
2239
|
+
confidence=0.95
|
|
2240
|
+
)
|
|
2241
|
+
|
|
2242
|
+
return NodeSpec(
|
|
2243
|
+
name=name,
|
|
2244
|
+
fn=process_fn,
|
|
2245
|
+
in_model=ProcessorInput,
|
|
2246
|
+
out_model=ProcessorOutput,
|
|
2247
|
+
deps=frozenset(kwargs.get("deps", [])),
|
|
2248
|
+
)
|
|
2249
|
+
```
|
|
2250
|
+
|
|
2251
|
+
### In YAML
|
|
2252
|
+
|
|
2253
|
+
```yaml
|
|
2254
|
+
- kind: mycompany.nodes.TypedProcessorNode
|
|
2255
|
+
metadata:
|
|
2256
|
+
name: processor
|
|
2257
|
+
spec:
|
|
2258
|
+
input_schema:
|
|
2259
|
+
text: str
|
|
2260
|
+
options: dict
|
|
2261
|
+
output_schema:
|
|
2262
|
+
result: str
|
|
2263
|
+
confidence: float
|
|
2264
|
+
dependencies: []
|
|
2265
|
+
```
|
|
2266
|
+
|
|
2267
|
+
## Builder Pattern Nodes
|
|
2268
|
+
|
|
2269
|
+
For complex configuration, use the builder pattern:
|
|
2270
|
+
|
|
2271
|
+
```python
|
|
2272
|
+
@node(name="configurable_node", namespace="plugin")
|
|
2273
|
+
class ConfigurableNode(BaseNodeFactory):
|
|
2274
|
+
def __init__(self):
|
|
2275
|
+
super().__init__()
|
|
2276
|
+
self._name = None
|
|
2277
|
+
self._config = {}
|
|
2278
|
+
self._validators = []
|
|
2279
|
+
|
|
2280
|
+
def name(self, n: str) -> "ConfigurableNode":
|
|
2281
|
+
self._name = n
|
|
2282
|
+
return self
|
|
2283
|
+
|
|
2284
|
+
def config(self, **kwargs) -> "ConfigurableNode":
|
|
2285
|
+
self._config.update(kwargs)
|
|
2286
|
+
return self
|
|
2287
|
+
|
|
2288
|
+
def validate_with(self, validator) -> "ConfigurableNode":
|
|
2289
|
+
self._validators.append(validator)
|
|
2290
|
+
return self
|
|
2291
|
+
|
|
2292
|
+
def build(self) -> NodeSpec:
|
|
2293
|
+
async def process_fn(input_data):
|
|
2294
|
+
for validator in self._validators:
|
|
2295
|
+
input_data = validator(input_data)
|
|
2296
|
+
return {"processed": True, **self._config}
|
|
2297
|
+
|
|
2298
|
+
return NodeSpec(
|
|
2299
|
+
name=self._name,
|
|
2300
|
+
fn=process_fn,
|
|
2301
|
+
params=self._config,
|
|
2302
|
+
)
|
|
2303
|
+
```
|
|
2304
|
+
|
|
2305
|
+
Usage:
|
|
2306
|
+
```python
|
|
2307
|
+
node = (ConfigurableNode()
|
|
2308
|
+
.name("my_node")
|
|
2309
|
+
.config(threshold=0.5, mode="strict")
|
|
2310
|
+
.validate_with(my_validator)
|
|
2311
|
+
.build())
|
|
2312
|
+
```
|
|
2313
|
+
|
|
2314
|
+
## Providing YAML Schema
|
|
2315
|
+
|
|
2316
|
+
For MCP tools to show proper schemas, add `_yaml_schema`:
|
|
2317
|
+
|
|
2318
|
+
```python
|
|
2319
|
+
@node(name="documented_node", namespace="plugin")
|
|
2320
|
+
class DocumentedNode(BaseNodeFactory):
|
|
2321
|
+
# Schema for MCP tools and documentation
|
|
2322
|
+
_yaml_schema = {
|
|
2323
|
+
"type": "object",
|
|
2324
|
+
"properties": {
|
|
2325
|
+
"threshold": {
|
|
2326
|
+
"type": "number",
|
|
2327
|
+
"description": "Processing threshold (0-1)",
|
|
2328
|
+
"default": 0.5
|
|
2329
|
+
},
|
|
2330
|
+
"mode": {
|
|
2331
|
+
"type": "string",
|
|
2332
|
+
"enum": ["standard", "strict", "lenient"],
|
|
2333
|
+
"description": "Processing mode"
|
|
2334
|
+
}
|
|
2335
|
+
},
|
|
2336
|
+
"required": ["mode"]
|
|
2337
|
+
}
|
|
2338
|
+
|
|
2339
|
+
def __call__(self, name: str, threshold: float = 0.5, mode: str = "standard"):
|
|
2340
|
+
...
|
|
2341
|
+
```
|
|
2342
|
+
|
|
2343
|
+
## Best Practices
|
|
2344
|
+
|
|
2345
|
+
1. **Async Functions**: Use `async def` for the node function
|
|
2346
|
+
2. **Immutable**: Don't modify input_data; return new dict
|
|
2347
|
+
3. **Type Hints**: Add types for better IDE support
|
|
2348
|
+
4. **Docstrings**: Document purpose and parameters
|
|
2349
|
+
5. **Small & Focused**: Each node should do one thing well
|
|
2350
|
+
6. **Testable**: Design for easy unit testing
|
|
2351
|
+
|
|
2352
|
+
## Testing Custom Nodes
|
|
2353
|
+
|
|
2354
|
+
```python
|
|
2355
|
+
import pytest
|
|
2356
|
+
from mycompany.nodes import CustomProcessorNode
|
|
2357
|
+
|
|
2358
|
+
@pytest.mark.asyncio
|
|
2359
|
+
async def test_custom_processor():
|
|
2360
|
+
# Create node spec
|
|
2361
|
+
node_factory = CustomProcessorNode()
|
|
2362
|
+
node_spec = node_factory(name="test", threshold=0.5)
|
|
2363
|
+
|
|
2364
|
+
# Test the function
|
|
2365
|
+
result = await node_spec.fn({"score": 0.8})
|
|
2366
|
+
|
|
2367
|
+
assert result["status"] == "pass"
|
|
2368
|
+
```
|
|
2369
|
+
'''
|
|
2370
|
+
|
|
2371
|
+
|
|
2372
|
+
@mcp.tool() # type: ignore[misc]
|
|
2373
|
+
def get_custom_tool_guide() -> str:
|
|
2374
|
+
"""Get a guide for creating custom tools for agents.
|
|
2375
|
+
|
|
2376
|
+
Returns documentation on creating tools that agents can use
|
|
2377
|
+
during execution.
|
|
2378
|
+
|
|
2379
|
+
Returns
|
|
2380
|
+
-------
|
|
2381
|
+
Guide for creating custom tools
|
|
2382
|
+
"""
|
|
2383
|
+
# Try to load auto-generated documentation first
|
|
2384
|
+
generated = _load_generated_doc("tool_guide.md")
|
|
2385
|
+
if generated:
|
|
2386
|
+
return generated
|
|
2387
|
+
|
|
2388
|
+
# Fallback to static documentation
|
|
2389
|
+
return '''# Creating Custom Tools for hexDAG Agents
|
|
2390
|
+
|
|
2391
|
+
## Overview
|
|
2392
|
+
|
|
2393
|
+
Tools are functions that agents can invoke during execution. They enable
|
|
2394
|
+
agents to interact with external systems, perform calculations, or access data.
|
|
2395
|
+
|
|
2396
|
+
## Quick Start
|
|
2397
|
+
|
|
2398
|
+
### Simple Tool Function
|
|
2399
|
+
|
|
2400
|
+
```python
|
|
2401
|
+
from hexdag.core.registry import tool
|
|
2402
|
+
|
|
2403
|
+
@tool(name="calculate", namespace="custom", description="Perform calculations")
|
|
2404
|
+
def calculate(expression: str) -> str:
|
|
2405
|
+
"""Safely evaluate a mathematical expression.
|
|
2406
|
+
|
|
2407
|
+
Args:
|
|
2408
|
+
expression: Math expression like "2 + 2" or "sqrt(16)"
|
|
2409
|
+
|
|
2410
|
+
Returns:
|
|
2411
|
+
Result as a string
|
|
2412
|
+
"""
|
|
2413
|
+
import ast
|
|
2414
|
+
import operator
|
|
2415
|
+
|
|
2416
|
+
# Safe evaluation (simplified example)
|
|
2417
|
+
result = eval(expression, {"__builtins__": {}}, {"sqrt": math.sqrt})
|
|
2418
|
+
return str(result)
|
|
2419
|
+
```
|
|
2420
|
+
|
|
2421
|
+
### Async Tool
|
|
2422
|
+
|
|
2423
|
+
```python
|
|
2424
|
+
@tool(name="fetch_data", namespace="custom", description="Fetch data from API")
|
|
2425
|
+
async def fetch_data(url: str, timeout: int = 30) -> dict:
|
|
2426
|
+
"""Fetch JSON data from a URL.
|
|
2427
|
+
|
|
2428
|
+
Args:
|
|
2429
|
+
url: API endpoint URL
|
|
2430
|
+
timeout: Request timeout in seconds
|
|
2431
|
+
|
|
2432
|
+
Returns:
|
|
2433
|
+
JSON response as dict
|
|
2434
|
+
"""
|
|
2435
|
+
import httpx
|
|
2436
|
+
|
|
2437
|
+
async with httpx.AsyncClient() as client:
|
|
2438
|
+
response = await client.get(url, timeout=timeout)
|
|
2439
|
+
return response.json()
|
|
2440
|
+
```
|
|
2441
|
+
|
|
2442
|
+
## Tool Schema Generation
|
|
2443
|
+
|
|
2444
|
+
Tool schemas are auto-generated from:
|
|
2445
|
+
- Function signature (parameter types)
|
|
2446
|
+
- Docstring (descriptions)
|
|
2447
|
+
- Type hints (for validation)
|
|
2448
|
+
|
|
2449
|
+
```python
|
|
2450
|
+
@tool(name="search", namespace="custom", description="Search documents")
|
|
2451
|
+
def search(
|
|
2452
|
+
query: str,
|
|
2453
|
+
limit: int = 10,
|
|
2454
|
+
filters: dict | None = None
|
|
2455
|
+
) -> list[dict]:
|
|
2456
|
+
"""Search for documents matching query.
|
|
2457
|
+
|
|
2458
|
+
Args:
|
|
2459
|
+
query: Search query string
|
|
2460
|
+
limit: Maximum results to return (default: 10)
|
|
2461
|
+
filters: Optional filters like {"category": "tech"}
|
|
2462
|
+
|
|
2463
|
+
Returns:
|
|
2464
|
+
List of matching documents
|
|
2465
|
+
"""
|
|
2466
|
+
# Implementation
|
|
2467
|
+
...
|
|
2468
|
+
```
|
|
2469
|
+
|
|
2470
|
+
This generates schema:
|
|
2471
|
+
```json
|
|
2472
|
+
{
|
|
2473
|
+
"name": "search",
|
|
2474
|
+
"description": "Search for documents matching query.",
|
|
2475
|
+
"parameters": {
|
|
2476
|
+
"type": "object",
|
|
2477
|
+
"properties": {
|
|
2478
|
+
"query": {"type": "string", "description": "Search query string"},
|
|
2479
|
+
"limit": {"type": "integer", "default": 10},
|
|
2480
|
+
"filters": {"type": "object", "nullable": true}
|
|
2481
|
+
},
|
|
2482
|
+
"required": ["query"]
|
|
2483
|
+
}
|
|
2484
|
+
}
|
|
2485
|
+
```
|
|
2486
|
+
|
|
2487
|
+
## Using Tools with Agents
|
|
2488
|
+
|
|
2489
|
+
### In YAML Pipeline
|
|
2490
|
+
|
|
2491
|
+
```yaml
|
|
2492
|
+
- kind: agent_node
|
|
2493
|
+
metadata:
|
|
2494
|
+
name: research_agent
|
|
2495
|
+
spec:
|
|
2496
|
+
initial_prompt_template: "Research: {{topic}}"
|
|
2497
|
+
max_steps: 5
|
|
2498
|
+
tools:
|
|
2499
|
+
- mycompany.tools.search
|
|
2500
|
+
- mycompany.tools.fetch_data
|
|
2501
|
+
- mycompany.tools.calculate
|
|
2502
|
+
dependencies: []
|
|
2503
|
+
```
|
|
2504
|
+
|
|
2505
|
+
### Tool Invocation Format
|
|
2506
|
+
|
|
2507
|
+
Agents invoke tools using this format in their output:
|
|
2508
|
+
```
|
|
2509
|
+
INVOKE_TOOL: tool_name(param1="value", param2=123)
|
|
2510
|
+
```
|
|
2511
|
+
|
|
2512
|
+
## Built-in Tools
|
|
2513
|
+
|
|
2514
|
+
hexDAG provides these built-in tools:
|
|
2515
|
+
|
|
2516
|
+
| Tool | Description |
|
|
2517
|
+
|------|-------------|
|
|
2518
|
+
| `tool_end` | Signal agent completion |
|
|
2519
|
+
| `tool_noop` | No operation (thinking step) |
|
|
2520
|
+
|
|
2521
|
+
## Best Practices
|
|
2522
|
+
|
|
2523
|
+
1. **Type Hints**: Always add parameter and return types
|
|
2524
|
+
2. **Docstrings**: Write clear descriptions for LLM understanding
|
|
2525
|
+
3. **Error Handling**: Return error messages, don't raise exceptions
|
|
2526
|
+
4. **Idempotent**: Tools should be safe to retry
|
|
2527
|
+
5. **Async**: Use async for I/O operations
|
|
2528
|
+
6. **Validation**: Validate inputs before processing
|
|
2529
|
+
'''
|
|
2530
|
+
|
|
2531
|
+
|
|
2532
|
+
@mcp.tool() # type: ignore[misc]
|
|
2533
|
+
def get_extension_guide(component_type: str | None = None) -> str:
|
|
2534
|
+
"""Get a guide for extending hexDAG with custom components.
|
|
2535
|
+
|
|
2536
|
+
Args
|
|
2537
|
+
----
|
|
2538
|
+
component_type: Optional specific type (adapter, node, tool, macro, policy)
|
|
2539
|
+
If not specified, returns overview of all extension points.
|
|
2540
|
+
|
|
2541
|
+
Returns
|
|
2542
|
+
-------
|
|
2543
|
+
Guide for the requested extension type or overview
|
|
2544
|
+
|
|
2545
|
+
Examples
|
|
2546
|
+
--------
|
|
2547
|
+
>>> get_extension_guide() # doctest: +SKIP
|
|
2548
|
+
# Extending hexDAG - Overview
|
|
2549
|
+
...
|
|
2550
|
+
>>> get_extension_guide("adapter") # doctest: +SKIP
|
|
2551
|
+
# See get_custom_adapter_guide() for details
|
|
2552
|
+
"""
|
|
2553
|
+
if component_type == "adapter":
|
|
2554
|
+
return "Use get_custom_adapter_guide() for detailed adapter documentation."
|
|
2555
|
+
if component_type == "node":
|
|
2556
|
+
return "Use get_custom_node_guide() for detailed node documentation."
|
|
2557
|
+
if component_type == "tool":
|
|
2558
|
+
return "Use get_custom_tool_guide() for detailed tool documentation."
|
|
2559
|
+
|
|
2560
|
+
# Try to load auto-generated documentation first
|
|
2561
|
+
generated = _load_generated_doc("extension_guide.md")
|
|
2562
|
+
if generated:
|
|
2563
|
+
return generated
|
|
2564
|
+
|
|
2565
|
+
# Fallback to static documentation
|
|
2566
|
+
return """# Extending hexDAG - Overview
|
|
2567
|
+
|
|
2568
|
+
## Extension Points
|
|
2569
|
+
|
|
2570
|
+
hexDAG can be extended at multiple levels:
|
|
2571
|
+
|
|
2572
|
+
| Component | Purpose | Decorator |
|
|
2573
|
+
|-----------|---------|-----------|
|
|
2574
|
+
| **Adapter** | Connect to external services | `@adapter()` |
|
|
2575
|
+
| **Node** | Custom processing logic | `@node()` |
|
|
2576
|
+
| **Tool** | Agent-callable functions | `@tool()` |
|
|
2577
|
+
| **Macro** | Reusable pipeline patterns | `@macro()` |
|
|
2578
|
+
| **Policy** | Execution control rules | `@policy()` |
|
|
2579
|
+
|
|
2580
|
+
## Quick Reference
|
|
2581
|
+
|
|
2582
|
+
### Adapters
|
|
2583
|
+
```python
|
|
2584
|
+
@adapter("llm", name="my_llm", secrets={"api_key": "MY_API_KEY"})
|
|
2585
|
+
class MyLLMAdapter:
|
|
2586
|
+
def __init__(self, api_key: str, model: str = "default"):
|
|
2587
|
+
...
|
|
2588
|
+
```
|
|
2589
|
+
→ Use `get_custom_adapter_guide()` for full documentation
|
|
2590
|
+
|
|
2591
|
+
### Nodes
|
|
2592
|
+
```python
|
|
2593
|
+
@node(name="my_node", namespace="plugin")
|
|
2594
|
+
class MyNode(BaseNodeFactory):
|
|
2595
|
+
def __call__(self, name: str, **params) -> NodeSpec:
|
|
2596
|
+
...
|
|
2597
|
+
```
|
|
2598
|
+
→ Use `get_custom_node_guide()` for full documentation
|
|
2599
|
+
|
|
2600
|
+
### Tools
|
|
2601
|
+
```python
|
|
2602
|
+
@tool(name="my_tool", namespace="plugin", description="Does something")
|
|
2603
|
+
def my_tool(param: str) -> str:
|
|
2604
|
+
...
|
|
2605
|
+
```
|
|
2606
|
+
→ Use `get_custom_tool_guide()` for full documentation
|
|
2607
|
+
|
|
2608
|
+
### Macros
|
|
2609
|
+
```python
|
|
2610
|
+
@macro(name="my_pattern", namespace="plugin")
|
|
2611
|
+
class MyMacro(ConfigurableMacro):
|
|
2612
|
+
def expand(self, **params) -> list[NodeSpec]:
|
|
2613
|
+
# Return list of nodes that implement the pattern
|
|
2614
|
+
...
|
|
2615
|
+
```
|
|
2616
|
+
|
|
2617
|
+
### Policies
|
|
2618
|
+
```python
|
|
2619
|
+
@policy(name="my_policy", description="Custom retry logic")
|
|
2620
|
+
class MyPolicy:
|
|
2621
|
+
def __init__(self, max_retries: int = 3):
|
|
2622
|
+
...
|
|
2623
|
+
|
|
2624
|
+
async def evaluate(self, context: PolicyContext) -> PolicyResponse:
|
|
2625
|
+
...
|
|
2626
|
+
```
|
|
2627
|
+
|
|
2628
|
+
## Plugin Structure
|
|
2629
|
+
|
|
2630
|
+
Organize extensions in `hexdag_plugins/`:
|
|
2631
|
+
|
|
2632
|
+
```
|
|
2633
|
+
hexdag_plugins/
|
|
2634
|
+
├── my_adapter/
|
|
2635
|
+
│ ├── __init__.py
|
|
2636
|
+
│ ├── adapter.py
|
|
2637
|
+
│ ├── pyproject.toml
|
|
2638
|
+
│ └── tests/
|
|
2639
|
+
├── my_nodes/
|
|
2640
|
+
│ ├── __init__.py
|
|
2641
|
+
│ ├── processor.py
|
|
2642
|
+
│ └── analyzer.py
|
|
2643
|
+
└── my_tools/
|
|
2644
|
+
├── __init__.py
|
|
2645
|
+
└── search.py
|
|
2646
|
+
```
|
|
2647
|
+
|
|
2648
|
+
## Using Extensions in YAML
|
|
2649
|
+
|
|
2650
|
+
```yaml
|
|
2651
|
+
apiVersion: hexdag/v1
|
|
2652
|
+
kind: Pipeline
|
|
2653
|
+
metadata:
|
|
2654
|
+
name: extended-pipeline
|
|
2655
|
+
spec:
|
|
2656
|
+
# Aliases for cleaner references
|
|
2657
|
+
aliases:
|
|
2658
|
+
my_processor: mycompany.nodes.ProcessorNode
|
|
2659
|
+
my_analyzer: mycompany.nodes.AnalyzerNode
|
|
2660
|
+
|
|
2661
|
+
# Custom adapters
|
|
2662
|
+
ports:
|
|
2663
|
+
llm:
|
|
2664
|
+
adapter: mycompany.adapters.CustomLLMAdapter
|
|
2665
|
+
config:
|
|
2666
|
+
api_key: ${MY_API_KEY}
|
|
2667
|
+
|
|
2668
|
+
# Custom nodes
|
|
2669
|
+
nodes:
|
|
2670
|
+
- kind: my_processor
|
|
2671
|
+
metadata:
|
|
2672
|
+
name: step1
|
|
2673
|
+
spec:
|
|
2674
|
+
mode: fast
|
|
2675
|
+
dependencies: []
|
|
2676
|
+
|
|
2677
|
+
- kind: agent_node
|
|
2678
|
+
metadata:
|
|
2679
|
+
name: agent
|
|
2680
|
+
spec:
|
|
2681
|
+
tools:
|
|
2682
|
+
- mycompany.tools.search # Custom tool
|
|
2683
|
+
- mycompany.tools.analyze
|
|
2684
|
+
dependencies: [step1]
|
|
2685
|
+
```
|
|
2686
|
+
|
|
2687
|
+
## MCP Tools for Development
|
|
2688
|
+
|
|
2689
|
+
Use these MCP tools when building pipelines:
|
|
2690
|
+
|
|
2691
|
+
| Tool | Purpose |
|
|
2692
|
+
|------|---------|
|
|
2693
|
+
| `list_nodes()` | See available nodes |
|
|
2694
|
+
| `list_adapters()` | See available adapters |
|
|
2695
|
+
| `get_component_schema()` | Get config schema |
|
|
2696
|
+
| `get_syntax_reference()` | Variable syntax help |
|
|
2697
|
+
| `validate_yaml_pipeline()` | Validate your YAML |
|
|
2698
|
+
| `get_custom_adapter_guide()` | Adapter creation guide |
|
|
2699
|
+
| `get_custom_node_guide()` | Node creation guide |
|
|
2700
|
+
| `get_custom_tool_guide()` | Tool creation guide |
|
|
2701
|
+
| `init_pipeline()` | Create new empty pipeline |
|
|
2702
|
+
| `add_node_to_pipeline()` | Add node to pipeline |
|
|
2703
|
+
| `remove_node_from_pipeline()` | Remove node from pipeline |
|
|
2704
|
+
| `update_node_config()` | Update node configuration |
|
|
2705
|
+
| `list_pipeline_nodes()` | List nodes with dependencies |
|
|
2706
|
+
"""
|
|
2707
|
+
|
|
2708
|
+
|
|
2709
|
+
# ============================================================================
|
|
2710
|
+
# Pipeline Manipulation Tools
|
|
2711
|
+
# ============================================================================
|
|
2712
|
+
|
|
2713
|
+
|
|
2714
|
+
def _parse_pipeline_yaml(yaml_content: str) -> tuple[dict[str, Any], str | None]:
|
|
2715
|
+
"""Parse pipeline YAML with error handling.
|
|
2716
|
+
|
|
2717
|
+
Parameters
|
|
2718
|
+
----------
|
|
2719
|
+
yaml_content : str
|
|
2720
|
+
YAML content to parse
|
|
2721
|
+
|
|
2722
|
+
Returns
|
|
2723
|
+
-------
|
|
2724
|
+
tuple[dict[str, Any], str | None]
|
|
2725
|
+
Tuple of (parsed_config, error_message)
|
|
2726
|
+
If error_message is not None, parsed_config will be empty dict
|
|
2727
|
+
"""
|
|
2728
|
+
try:
|
|
2729
|
+
config = yaml.safe_load(yaml_content)
|
|
2730
|
+
if not isinstance(config, dict):
|
|
2731
|
+
return {}, "YAML must be a dictionary/object"
|
|
2732
|
+
return config, None
|
|
2733
|
+
except yaml.YAMLError as e:
|
|
2734
|
+
return {}, f"YAML parse error: {e}"
|
|
2735
|
+
|
|
2736
|
+
|
|
2737
|
+
def _find_node_by_name(nodes: list[dict[str, Any]], name: str) -> int | None:
|
|
2738
|
+
"""Find node index by metadata.name.
|
|
2739
|
+
|
|
2740
|
+
Parameters
|
|
2741
|
+
----------
|
|
2742
|
+
nodes : list[dict[str, Any]]
|
|
2743
|
+
List of node configurations
|
|
2744
|
+
name : str
|
|
2745
|
+
Node name to find
|
|
2746
|
+
|
|
2747
|
+
Returns
|
|
2748
|
+
-------
|
|
2749
|
+
int | None
|
|
2750
|
+
Index of node if found, None otherwise
|
|
2751
|
+
"""
|
|
2752
|
+
for i, node in enumerate(nodes):
|
|
2753
|
+
node_name = node.get("metadata", {}).get("name")
|
|
2754
|
+
if node_name == name:
|
|
2755
|
+
return i
|
|
2756
|
+
return None
|
|
2757
|
+
|
|
2758
|
+
|
|
2759
|
+
def _get_node_names(nodes: list[dict[str, Any]]) -> set[str]:
|
|
2760
|
+
"""Get set of all node names.
|
|
2761
|
+
|
|
2762
|
+
Parameters
|
|
2763
|
+
----------
|
|
2764
|
+
nodes : list[dict[str, Any]]
|
|
2765
|
+
List of node configurations
|
|
2766
|
+
|
|
2767
|
+
Returns
|
|
2768
|
+
-------
|
|
2769
|
+
set[str]
|
|
2770
|
+
Set of node names
|
|
2771
|
+
"""
|
|
2772
|
+
names = set()
|
|
2773
|
+
for node in nodes:
|
|
2774
|
+
name = node.get("metadata", {}).get("name")
|
|
2775
|
+
if name:
|
|
2776
|
+
names.add(name)
|
|
2777
|
+
return names
|
|
2778
|
+
|
|
2779
|
+
|
|
2780
|
+
def _compute_execution_order(nodes: list[dict[str, Any]]) -> list[str]:
|
|
2781
|
+
"""Compute topological execution order using Kahn's algorithm.
|
|
2782
|
+
|
|
2783
|
+
Parameters
|
|
2784
|
+
----------
|
|
2785
|
+
nodes : list[dict[str, Any]]
|
|
2786
|
+
List of node configurations
|
|
2787
|
+
|
|
2788
|
+
Returns
|
|
2789
|
+
-------
|
|
2790
|
+
list[str]
|
|
2791
|
+
Node names in topological order
|
|
2792
|
+
"""
|
|
2793
|
+
# Build adjacency list and in-degree count
|
|
2794
|
+
in_degree: dict[str, int] = {}
|
|
2795
|
+
graph: dict[str, list[str]] = {}
|
|
2796
|
+
all_nodes: set[str] = set()
|
|
2797
|
+
|
|
2798
|
+
for node in nodes:
|
|
2799
|
+
name = node.get("metadata", {}).get("name")
|
|
2800
|
+
if not name:
|
|
2801
|
+
continue
|
|
2802
|
+
all_nodes.add(name)
|
|
2803
|
+
in_degree.setdefault(name, 0)
|
|
2804
|
+
graph.setdefault(name, [])
|
|
2805
|
+
|
|
2806
|
+
deps = node.get("dependencies", [])
|
|
2807
|
+
for dep in deps:
|
|
2808
|
+
if dep in all_nodes or dep in in_degree:
|
|
2809
|
+
graph.setdefault(dep, []).append(name)
|
|
2810
|
+
in_degree[name] = in_degree.get(name, 0) + 1
|
|
2811
|
+
|
|
2812
|
+
# Kahn's algorithm
|
|
2813
|
+
queue = [n for n in all_nodes if in_degree.get(n, 0) == 0]
|
|
2814
|
+
result: list[str] = []
|
|
2815
|
+
|
|
2816
|
+
while queue:
|
|
2817
|
+
node = queue.pop(0)
|
|
2818
|
+
result.append(node)
|
|
2819
|
+
for neighbor in graph.get(node, []):
|
|
2820
|
+
in_degree[neighbor] -= 1
|
|
2821
|
+
if in_degree[neighbor] == 0:
|
|
2822
|
+
queue.append(neighbor)
|
|
2823
|
+
|
|
2824
|
+
return result
|
|
2825
|
+
|
|
2826
|
+
|
|
2827
|
+
@mcp.tool() # type: ignore[misc]
|
|
2828
|
+
def init_pipeline(name: str, description: str = "") -> str:
|
|
2829
|
+
"""Create a new minimal pipeline YAML configuration.
|
|
2830
|
+
|
|
2831
|
+
Creates an empty pipeline structure ready for adding nodes.
|
|
2832
|
+
|
|
2833
|
+
Parameters
|
|
2834
|
+
----------
|
|
2835
|
+
name : str
|
|
2836
|
+
Pipeline name (used in metadata.name)
|
|
2837
|
+
description : str, optional
|
|
2838
|
+
Pipeline description
|
|
2839
|
+
|
|
2840
|
+
Returns
|
|
2841
|
+
-------
|
|
2842
|
+
str
|
|
2843
|
+
JSON with {success: bool, yaml_content: str}
|
|
2844
|
+
"""
|
|
2845
|
+
# Use centralized helper for pipeline creation
|
|
2846
|
+
config = _create_pipeline_base(name, description)
|
|
2847
|
+
yaml_content = yaml.dump(config, sort_keys=False, default_flow_style=False)
|
|
2848
|
+
|
|
2849
|
+
return json.dumps(
|
|
2850
|
+
{
|
|
2851
|
+
"success": True,
|
|
2852
|
+
"yaml_content": yaml_content,
|
|
2853
|
+
"message": f"Created empty pipeline '{name}'",
|
|
2854
|
+
},
|
|
2855
|
+
indent=2,
|
|
2856
|
+
)
|
|
2857
|
+
|
|
2858
|
+
|
|
2859
|
+
@mcp.tool() # type: ignore[misc]
|
|
2860
|
+
def add_node_to_pipeline(yaml_content: str, node_config: dict[str, Any]) -> str:
|
|
2861
|
+
"""Add a node to an existing pipeline YAML.
|
|
2862
|
+
|
|
2863
|
+
Parameters
|
|
2864
|
+
----------
|
|
2865
|
+
yaml_content : str
|
|
2866
|
+
Existing pipeline YAML as string
|
|
2867
|
+
node_config : dict[str, Any]
|
|
2868
|
+
Node configuration with keys:
|
|
2869
|
+
- kind: Node type (e.g., "llm_node", "function_node")
|
|
2870
|
+
- name: Unique node identifier
|
|
2871
|
+
- spec: Node-specific configuration dict
|
|
2872
|
+
- dependencies: Optional list of dependency node names
|
|
2873
|
+
|
|
2874
|
+
Returns
|
|
2875
|
+
-------
|
|
2876
|
+
str
|
|
2877
|
+
JSON with {success: bool, yaml_content: str, warnings: list, node_count: int}
|
|
2878
|
+
"""
|
|
2879
|
+
config, error = _parse_pipeline_yaml(yaml_content)
|
|
2880
|
+
if error:
|
|
2881
|
+
return json.dumps({"success": False, "error": error}, indent=2)
|
|
2882
|
+
|
|
2883
|
+
# Validate required fields
|
|
2884
|
+
if "kind" not in node_config:
|
|
2885
|
+
return json.dumps(
|
|
2886
|
+
{"success": False, "error": "node_config must have 'kind' field"}, indent=2
|
|
2887
|
+
)
|
|
2888
|
+
if "name" not in node_config:
|
|
2889
|
+
return json.dumps(
|
|
2890
|
+
{"success": False, "error": "node_config must have 'name' field"}, indent=2
|
|
2891
|
+
)
|
|
2892
|
+
|
|
2893
|
+
# Get or create nodes list
|
|
2894
|
+
spec = config.setdefault("spec", {})
|
|
2895
|
+
nodes = spec.setdefault("nodes", [])
|
|
2896
|
+
|
|
2897
|
+
# Check for duplicate name
|
|
2898
|
+
existing_names = _get_node_names(nodes)
|
|
2899
|
+
node_name = node_config["name"]
|
|
2900
|
+
if node_name in existing_names:
|
|
2901
|
+
return json.dumps(
|
|
2902
|
+
{"success": False, "error": f"Node '{node_name}' already exists"}, indent=2
|
|
2903
|
+
)
|
|
2904
|
+
|
|
2905
|
+
# Build node structure
|
|
2906
|
+
new_node: dict[str, Any] = {
|
|
2907
|
+
"kind": node_config["kind"],
|
|
2908
|
+
"metadata": {"name": node_name},
|
|
2909
|
+
"spec": node_config.get("spec", {}),
|
|
2910
|
+
"dependencies": node_config.get("dependencies", []),
|
|
2911
|
+
}
|
|
2912
|
+
|
|
2913
|
+
# Check for missing dependencies (warn, don't fail)
|
|
2914
|
+
deps = node_config.get("dependencies", [])
|
|
2915
|
+
warnings: list[str] = [
|
|
2916
|
+
f"Dependency '{dep}' not found in pipeline" for dep in deps if dep not in existing_names
|
|
2917
|
+
]
|
|
2918
|
+
|
|
2919
|
+
nodes.append(new_node)
|
|
2920
|
+
|
|
2921
|
+
yaml_output = yaml.dump(config, sort_keys=False, default_flow_style=False)
|
|
2922
|
+
|
|
2923
|
+
return json.dumps(
|
|
2924
|
+
{
|
|
2925
|
+
"success": True,
|
|
2926
|
+
"yaml_content": yaml_output,
|
|
2927
|
+
"node_count": len(nodes),
|
|
2928
|
+
"warnings": warnings if warnings else None,
|
|
2929
|
+
},
|
|
2930
|
+
indent=2,
|
|
2931
|
+
)
|
|
2932
|
+
|
|
2933
|
+
|
|
2934
|
+
@mcp.tool() # type: ignore[misc]
|
|
2935
|
+
def remove_node_from_pipeline(yaml_content: str, node_name: str) -> str:
|
|
2936
|
+
"""Remove a node from a pipeline YAML.
|
|
2937
|
+
|
|
2938
|
+
Parameters
|
|
2939
|
+
----------
|
|
2940
|
+
yaml_content : str
|
|
2941
|
+
Existing pipeline YAML as string
|
|
2942
|
+
node_name : str
|
|
2943
|
+
Name of the node to remove
|
|
2944
|
+
|
|
2945
|
+
Returns
|
|
2946
|
+
-------
|
|
2947
|
+
str
|
|
2948
|
+
JSON with {success: bool, yaml_content: str, warnings: list}
|
|
2949
|
+
Warns if other nodes depend on the removed node.
|
|
2950
|
+
"""
|
|
2951
|
+
config, error = _parse_pipeline_yaml(yaml_content)
|
|
2952
|
+
if error:
|
|
2953
|
+
return json.dumps({"success": False, "error": error}, indent=2)
|
|
2954
|
+
|
|
2955
|
+
nodes = config.get("spec", {}).get("nodes", [])
|
|
2956
|
+
|
|
2957
|
+
# Find node index
|
|
2958
|
+
node_idx = _find_node_by_name(nodes, node_name)
|
|
2959
|
+
if node_idx is None:
|
|
2960
|
+
return json.dumps({"success": False, "error": f"Node '{node_name}' not found"}, indent=2)
|
|
2961
|
+
|
|
2962
|
+
# Check for dependents
|
|
2963
|
+
warnings: list[str] = []
|
|
2964
|
+
for node in nodes:
|
|
2965
|
+
deps = node.get("dependencies", [])
|
|
2966
|
+
if node_name in deps:
|
|
2967
|
+
dependent_name = node.get("metadata", {}).get("name", "unknown")
|
|
2968
|
+
warnings.append(f"Node '{dependent_name}' depends on '{node_name}'")
|
|
2969
|
+
|
|
2970
|
+
# Remove the node
|
|
2971
|
+
nodes.pop(node_idx)
|
|
2972
|
+
|
|
2973
|
+
yaml_output = yaml.dump(config, sort_keys=False, default_flow_style=False)
|
|
2974
|
+
|
|
2975
|
+
return json.dumps(
|
|
2976
|
+
{
|
|
2977
|
+
"success": True,
|
|
2978
|
+
"yaml_content": yaml_output,
|
|
2979
|
+
"node_count": len(nodes),
|
|
2980
|
+
"removed": True,
|
|
2981
|
+
"warnings": warnings if warnings else None,
|
|
2982
|
+
},
|
|
2983
|
+
indent=2,
|
|
2984
|
+
)
|
|
2985
|
+
|
|
2986
|
+
|
|
2987
|
+
@mcp.tool() # type: ignore[misc]
|
|
2988
|
+
def update_node_config(yaml_content: str, node_name: str, config_updates: dict[str, Any]) -> str:
|
|
2989
|
+
"""Update a node's configuration in pipeline YAML.
|
|
2990
|
+
|
|
2991
|
+
Parameters
|
|
2992
|
+
----------
|
|
2993
|
+
yaml_content : str
|
|
2994
|
+
Existing pipeline YAML as string
|
|
2995
|
+
node_name : str
|
|
2996
|
+
Name of the node to update
|
|
2997
|
+
config_updates : dict[str, Any]
|
|
2998
|
+
Updates to apply:
|
|
2999
|
+
- spec: Dict of spec fields to merge/update
|
|
3000
|
+
- dependencies: New dependencies list (replaces existing)
|
|
3001
|
+
- kind: New node type (use with caution)
|
|
3002
|
+
|
|
3003
|
+
Returns
|
|
3004
|
+
-------
|
|
3005
|
+
str
|
|
3006
|
+
JSON with {success: bool, yaml_content: str}
|
|
3007
|
+
"""
|
|
3008
|
+
config, error = _parse_pipeline_yaml(yaml_content)
|
|
3009
|
+
if error:
|
|
3010
|
+
return json.dumps({"success": False, "error": error}, indent=2)
|
|
3011
|
+
|
|
3012
|
+
nodes = config.get("spec", {}).get("nodes", [])
|
|
3013
|
+
|
|
3014
|
+
# Find node index
|
|
3015
|
+
node_idx = _find_node_by_name(nodes, node_name)
|
|
3016
|
+
if node_idx is None:
|
|
3017
|
+
return json.dumps({"success": False, "error": f"Node '{node_name}' not found"}, indent=2)
|
|
3018
|
+
|
|
3019
|
+
node = nodes[node_idx]
|
|
3020
|
+
warnings: list[str] = []
|
|
3021
|
+
|
|
3022
|
+
# Apply updates
|
|
3023
|
+
if "spec" in config_updates:
|
|
3024
|
+
# Deep merge spec updates
|
|
3025
|
+
node_spec = node.setdefault("spec", {})
|
|
3026
|
+
for key, value in config_updates["spec"].items():
|
|
3027
|
+
node_spec[key] = value
|
|
3028
|
+
|
|
3029
|
+
if "dependencies" in config_updates:
|
|
3030
|
+
# Replace dependencies
|
|
3031
|
+
node["dependencies"] = config_updates["dependencies"]
|
|
3032
|
+
|
|
3033
|
+
if "kind" in config_updates:
|
|
3034
|
+
# Change node type (warn user)
|
|
3035
|
+
old_kind = node.get("kind")
|
|
3036
|
+
new_kind = config_updates["kind"]
|
|
3037
|
+
if old_kind != new_kind:
|
|
3038
|
+
warnings.append(f"Changed node type from '{old_kind}' to '{new_kind}'")
|
|
3039
|
+
node["kind"] = new_kind
|
|
3040
|
+
|
|
3041
|
+
yaml_output = yaml.dump(config, sort_keys=False, default_flow_style=False)
|
|
3042
|
+
|
|
3043
|
+
return json.dumps(
|
|
3044
|
+
{
|
|
3045
|
+
"success": True,
|
|
3046
|
+
"yaml_content": yaml_output,
|
|
3047
|
+
"warnings": warnings if warnings else None,
|
|
3048
|
+
},
|
|
3049
|
+
indent=2,
|
|
3050
|
+
)
|
|
3051
|
+
|
|
3052
|
+
|
|
3053
|
+
@mcp.tool() # type: ignore[misc]
|
|
3054
|
+
def list_pipeline_nodes(yaml_content: str) -> str:
|
|
3055
|
+
"""List all nodes in a pipeline with their dependencies.
|
|
3056
|
+
|
|
3057
|
+
Parameters
|
|
3058
|
+
----------
|
|
3059
|
+
yaml_content : str
|
|
3060
|
+
Pipeline YAML as string
|
|
3061
|
+
|
|
3062
|
+
Returns
|
|
3063
|
+
-------
|
|
3064
|
+
str
|
|
3065
|
+
JSON with:
|
|
3066
|
+
{
|
|
3067
|
+
success: bool,
|
|
3068
|
+
pipeline_name: str,
|
|
3069
|
+
node_count: int,
|
|
3070
|
+
nodes: [{name, kind, dependencies, dependents}],
|
|
3071
|
+
execution_order: [str]
|
|
3072
|
+
}
|
|
3073
|
+
"""
|
|
3074
|
+
config, error = _parse_pipeline_yaml(yaml_content)
|
|
3075
|
+
if error:
|
|
3076
|
+
return json.dumps({"success": False, "error": error}, indent=2)
|
|
3077
|
+
|
|
3078
|
+
pipeline_name = config.get("metadata", {}).get("name", "unknown")
|
|
3079
|
+
nodes = config.get("spec", {}).get("nodes", [])
|
|
3080
|
+
|
|
3081
|
+
# Build node info with reverse dependencies
|
|
3082
|
+
node_infos: list[dict[str, Any]] = []
|
|
3083
|
+
all_names = _get_node_names(nodes)
|
|
3084
|
+
|
|
3085
|
+
# Build reverse dependency map
|
|
3086
|
+
dependents_map: dict[str, list[str]] = {name: [] for name in all_names}
|
|
3087
|
+
for node in nodes:
|
|
3088
|
+
node_name = node.get("metadata", {}).get("name")
|
|
3089
|
+
deps = node.get("dependencies", [])
|
|
3090
|
+
for dep in deps:
|
|
3091
|
+
if dep in dependents_map:
|
|
3092
|
+
dependents_map[dep].append(node_name)
|
|
3093
|
+
|
|
3094
|
+
for node in nodes:
|
|
3095
|
+
node_name = node.get("metadata", {}).get("name", "unknown")
|
|
3096
|
+
node_infos.append({
|
|
3097
|
+
"name": node_name,
|
|
3098
|
+
"kind": node.get("kind", "unknown"),
|
|
3099
|
+
"dependencies": node.get("dependencies", []),
|
|
3100
|
+
"dependents": dependents_map.get(node_name, []),
|
|
3101
|
+
})
|
|
3102
|
+
|
|
3103
|
+
# Compute execution order
|
|
3104
|
+
execution_order = _compute_execution_order(nodes)
|
|
3105
|
+
|
|
3106
|
+
return json.dumps(
|
|
3107
|
+
{
|
|
3108
|
+
"success": True,
|
|
3109
|
+
"pipeline_name": pipeline_name,
|
|
3110
|
+
"node_count": len(nodes),
|
|
3111
|
+
"nodes": node_infos,
|
|
3112
|
+
"execution_order": execution_order,
|
|
3113
|
+
},
|
|
3114
|
+
indent=2,
|
|
3115
|
+
)
|
|
3116
|
+
|
|
3117
|
+
|
|
3118
|
+
# Run the server
|
|
3119
|
+
if __name__ == "__main__":
|
|
3120
|
+
mcp.run()
|