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.
Files changed (261) hide show
  1. hexdag/__init__.py +116 -0
  2. hexdag/__main__.py +30 -0
  3. hexdag/adapters/executors/__init__.py +5 -0
  4. hexdag/adapters/executors/local_executor.py +316 -0
  5. hexdag/builtin/__init__.py +6 -0
  6. hexdag/builtin/adapters/__init__.py +51 -0
  7. hexdag/builtin/adapters/anthropic/__init__.py +5 -0
  8. hexdag/builtin/adapters/anthropic/anthropic_adapter.py +151 -0
  9. hexdag/builtin/adapters/database/__init__.py +6 -0
  10. hexdag/builtin/adapters/database/csv/csv_adapter.py +249 -0
  11. hexdag/builtin/adapters/database/pgvector/__init__.py +5 -0
  12. hexdag/builtin/adapters/database/pgvector/pgvector_adapter.py +478 -0
  13. hexdag/builtin/adapters/database/sqlalchemy/sqlalchemy_adapter.py +252 -0
  14. hexdag/builtin/adapters/database/sqlite/__init__.py +5 -0
  15. hexdag/builtin/adapters/database/sqlite/sqlite_adapter.py +410 -0
  16. hexdag/builtin/adapters/local/README.md +59 -0
  17. hexdag/builtin/adapters/local/__init__.py +7 -0
  18. hexdag/builtin/adapters/local/local_observer_manager.py +696 -0
  19. hexdag/builtin/adapters/memory/__init__.py +47 -0
  20. hexdag/builtin/adapters/memory/file_memory_adapter.py +297 -0
  21. hexdag/builtin/adapters/memory/in_memory_memory.py +216 -0
  22. hexdag/builtin/adapters/memory/schemas.py +57 -0
  23. hexdag/builtin/adapters/memory/session_memory.py +178 -0
  24. hexdag/builtin/adapters/memory/sqlite_memory_adapter.py +215 -0
  25. hexdag/builtin/adapters/memory/state_memory.py +280 -0
  26. hexdag/builtin/adapters/mock/README.md +89 -0
  27. hexdag/builtin/adapters/mock/__init__.py +15 -0
  28. hexdag/builtin/adapters/mock/hexdag.toml +50 -0
  29. hexdag/builtin/adapters/mock/mock_database.py +225 -0
  30. hexdag/builtin/adapters/mock/mock_embedding.py +223 -0
  31. hexdag/builtin/adapters/mock/mock_llm.py +177 -0
  32. hexdag/builtin/adapters/mock/mock_tool_adapter.py +192 -0
  33. hexdag/builtin/adapters/mock/mock_tool_router.py +232 -0
  34. hexdag/builtin/adapters/openai/__init__.py +5 -0
  35. hexdag/builtin/adapters/openai/openai_adapter.py +634 -0
  36. hexdag/builtin/adapters/secret/__init__.py +7 -0
  37. hexdag/builtin/adapters/secret/local_secret_adapter.py +248 -0
  38. hexdag/builtin/adapters/unified_tool_router.py +280 -0
  39. hexdag/builtin/macros/__init__.py +17 -0
  40. hexdag/builtin/macros/conversation_agent.py +390 -0
  41. hexdag/builtin/macros/llm_macro.py +151 -0
  42. hexdag/builtin/macros/reasoning_agent.py +423 -0
  43. hexdag/builtin/macros/tool_macro.py +380 -0
  44. hexdag/builtin/nodes/__init__.py +38 -0
  45. hexdag/builtin/nodes/_discovery.py +123 -0
  46. hexdag/builtin/nodes/agent_node.py +696 -0
  47. hexdag/builtin/nodes/base_node_factory.py +242 -0
  48. hexdag/builtin/nodes/composite_node.py +926 -0
  49. hexdag/builtin/nodes/data_node.py +201 -0
  50. hexdag/builtin/nodes/expression_node.py +487 -0
  51. hexdag/builtin/nodes/function_node.py +454 -0
  52. hexdag/builtin/nodes/llm_node.py +491 -0
  53. hexdag/builtin/nodes/loop_node.py +920 -0
  54. hexdag/builtin/nodes/mapped_input.py +518 -0
  55. hexdag/builtin/nodes/port_call_node.py +269 -0
  56. hexdag/builtin/nodes/tool_call_node.py +195 -0
  57. hexdag/builtin/nodes/tool_utils.py +390 -0
  58. hexdag/builtin/prompts/__init__.py +68 -0
  59. hexdag/builtin/prompts/base.py +422 -0
  60. hexdag/builtin/prompts/chat_prompts.py +303 -0
  61. hexdag/builtin/prompts/error_correction_prompts.py +320 -0
  62. hexdag/builtin/prompts/tool_prompts.py +160 -0
  63. hexdag/builtin/tools/builtin_tools.py +84 -0
  64. hexdag/builtin/tools/database_tools.py +164 -0
  65. hexdag/cli/__init__.py +17 -0
  66. hexdag/cli/__main__.py +7 -0
  67. hexdag/cli/commands/__init__.py +27 -0
  68. hexdag/cli/commands/build_cmd.py +812 -0
  69. hexdag/cli/commands/create_cmd.py +208 -0
  70. hexdag/cli/commands/docs_cmd.py +293 -0
  71. hexdag/cli/commands/generate_types_cmd.py +252 -0
  72. hexdag/cli/commands/init_cmd.py +188 -0
  73. hexdag/cli/commands/pipeline_cmd.py +494 -0
  74. hexdag/cli/commands/plugin_dev_cmd.py +529 -0
  75. hexdag/cli/commands/plugins_cmd.py +441 -0
  76. hexdag/cli/commands/studio_cmd.py +101 -0
  77. hexdag/cli/commands/validate_cmd.py +221 -0
  78. hexdag/cli/main.py +84 -0
  79. hexdag/core/__init__.py +83 -0
  80. hexdag/core/config/__init__.py +20 -0
  81. hexdag/core/config/loader.py +479 -0
  82. hexdag/core/config/models.py +150 -0
  83. hexdag/core/configurable.py +294 -0
  84. hexdag/core/context/__init__.py +37 -0
  85. hexdag/core/context/execution_context.py +378 -0
  86. hexdag/core/docs/__init__.py +26 -0
  87. hexdag/core/docs/extractors.py +678 -0
  88. hexdag/core/docs/generators.py +890 -0
  89. hexdag/core/docs/models.py +120 -0
  90. hexdag/core/domain/__init__.py +10 -0
  91. hexdag/core/domain/dag.py +1225 -0
  92. hexdag/core/exceptions.py +234 -0
  93. hexdag/core/expression_parser.py +569 -0
  94. hexdag/core/logging.py +449 -0
  95. hexdag/core/models/__init__.py +17 -0
  96. hexdag/core/models/base.py +138 -0
  97. hexdag/core/orchestration/__init__.py +46 -0
  98. hexdag/core/orchestration/body_executor.py +481 -0
  99. hexdag/core/orchestration/components/__init__.py +97 -0
  100. hexdag/core/orchestration/components/adapter_lifecycle_manager.py +113 -0
  101. hexdag/core/orchestration/components/checkpoint_manager.py +134 -0
  102. hexdag/core/orchestration/components/execution_coordinator.py +360 -0
  103. hexdag/core/orchestration/components/health_check_manager.py +176 -0
  104. hexdag/core/orchestration/components/input_mapper.py +143 -0
  105. hexdag/core/orchestration/components/lifecycle_manager.py +583 -0
  106. hexdag/core/orchestration/components/node_executor.py +377 -0
  107. hexdag/core/orchestration/components/secret_manager.py +202 -0
  108. hexdag/core/orchestration/components/wave_executor.py +158 -0
  109. hexdag/core/orchestration/constants.py +17 -0
  110. hexdag/core/orchestration/events/README.md +312 -0
  111. hexdag/core/orchestration/events/__init__.py +104 -0
  112. hexdag/core/orchestration/events/batching.py +330 -0
  113. hexdag/core/orchestration/events/decorators.py +139 -0
  114. hexdag/core/orchestration/events/events.py +573 -0
  115. hexdag/core/orchestration/events/observers/__init__.py +30 -0
  116. hexdag/core/orchestration/events/observers/core_observers.py +690 -0
  117. hexdag/core/orchestration/events/observers/models.py +111 -0
  118. hexdag/core/orchestration/events/taxonomy.py +269 -0
  119. hexdag/core/orchestration/hook_context.py +237 -0
  120. hexdag/core/orchestration/hooks.py +437 -0
  121. hexdag/core/orchestration/models.py +418 -0
  122. hexdag/core/orchestration/orchestrator.py +910 -0
  123. hexdag/core/orchestration/orchestrator_factory.py +275 -0
  124. hexdag/core/orchestration/port_wrappers.py +327 -0
  125. hexdag/core/orchestration/prompt/__init__.py +32 -0
  126. hexdag/core/orchestration/prompt/template.py +332 -0
  127. hexdag/core/pipeline_builder/__init__.py +21 -0
  128. hexdag/core/pipeline_builder/component_instantiator.py +386 -0
  129. hexdag/core/pipeline_builder/include_tag.py +265 -0
  130. hexdag/core/pipeline_builder/pipeline_config.py +133 -0
  131. hexdag/core/pipeline_builder/py_tag.py +223 -0
  132. hexdag/core/pipeline_builder/tag_discovery.py +268 -0
  133. hexdag/core/pipeline_builder/yaml_builder.py +1196 -0
  134. hexdag/core/pipeline_builder/yaml_validator.py +569 -0
  135. hexdag/core/ports/__init__.py +65 -0
  136. hexdag/core/ports/api_call.py +133 -0
  137. hexdag/core/ports/database.py +489 -0
  138. hexdag/core/ports/embedding.py +215 -0
  139. hexdag/core/ports/executor.py +237 -0
  140. hexdag/core/ports/file_storage.py +117 -0
  141. hexdag/core/ports/healthcheck.py +87 -0
  142. hexdag/core/ports/llm.py +551 -0
  143. hexdag/core/ports/memory.py +70 -0
  144. hexdag/core/ports/observer_manager.py +130 -0
  145. hexdag/core/ports/secret.py +145 -0
  146. hexdag/core/ports/tool_router.py +94 -0
  147. hexdag/core/ports_builder.py +623 -0
  148. hexdag/core/protocols.py +273 -0
  149. hexdag/core/resolver.py +304 -0
  150. hexdag/core/schema/__init__.py +9 -0
  151. hexdag/core/schema/generator.py +742 -0
  152. hexdag/core/secrets.py +242 -0
  153. hexdag/core/types.py +413 -0
  154. hexdag/core/utils/async_warnings.py +206 -0
  155. hexdag/core/utils/schema_conversion.py +78 -0
  156. hexdag/core/utils/sql_validation.py +86 -0
  157. hexdag/core/validation/secure_json.py +148 -0
  158. hexdag/core/yaml_macro.py +517 -0
  159. hexdag/mcp_server.py +3120 -0
  160. hexdag/studio/__init__.py +10 -0
  161. hexdag/studio/build_ui.py +92 -0
  162. hexdag/studio/server/__init__.py +1 -0
  163. hexdag/studio/server/main.py +100 -0
  164. hexdag/studio/server/routes/__init__.py +9 -0
  165. hexdag/studio/server/routes/execute.py +208 -0
  166. hexdag/studio/server/routes/export.py +558 -0
  167. hexdag/studio/server/routes/files.py +207 -0
  168. hexdag/studio/server/routes/plugins.py +419 -0
  169. hexdag/studio/server/routes/validate.py +220 -0
  170. hexdag/studio/ui/index.html +13 -0
  171. hexdag/studio/ui/package-lock.json +2992 -0
  172. hexdag/studio/ui/package.json +31 -0
  173. hexdag/studio/ui/postcss.config.js +6 -0
  174. hexdag/studio/ui/public/hexdag.svg +5 -0
  175. hexdag/studio/ui/src/App.tsx +251 -0
  176. hexdag/studio/ui/src/components/Canvas.tsx +408 -0
  177. hexdag/studio/ui/src/components/ContextMenu.tsx +187 -0
  178. hexdag/studio/ui/src/components/FileBrowser.tsx +123 -0
  179. hexdag/studio/ui/src/components/Header.tsx +181 -0
  180. hexdag/studio/ui/src/components/HexdagNode.tsx +193 -0
  181. hexdag/studio/ui/src/components/NodeInspector.tsx +512 -0
  182. hexdag/studio/ui/src/components/NodePalette.tsx +262 -0
  183. hexdag/studio/ui/src/components/NodePortsSection.tsx +403 -0
  184. hexdag/studio/ui/src/components/PluginManager.tsx +347 -0
  185. hexdag/studio/ui/src/components/PortsEditor.tsx +481 -0
  186. hexdag/studio/ui/src/components/PythonEditor.tsx +195 -0
  187. hexdag/studio/ui/src/components/ValidationPanel.tsx +105 -0
  188. hexdag/studio/ui/src/components/YamlEditor.tsx +196 -0
  189. hexdag/studio/ui/src/components/index.ts +8 -0
  190. hexdag/studio/ui/src/index.css +92 -0
  191. hexdag/studio/ui/src/main.tsx +10 -0
  192. hexdag/studio/ui/src/types/index.ts +123 -0
  193. hexdag/studio/ui/src/vite-env.d.ts +1 -0
  194. hexdag/studio/ui/tailwind.config.js +29 -0
  195. hexdag/studio/ui/tsconfig.json +37 -0
  196. hexdag/studio/ui/tsconfig.node.json +13 -0
  197. hexdag/studio/ui/vite.config.ts +35 -0
  198. hexdag/visualization/__init__.py +69 -0
  199. hexdag/visualization/dag_visualizer.py +1020 -0
  200. hexdag-0.5.0.dev1.dist-info/METADATA +369 -0
  201. hexdag-0.5.0.dev1.dist-info/RECORD +261 -0
  202. hexdag-0.5.0.dev1.dist-info/WHEEL +4 -0
  203. hexdag-0.5.0.dev1.dist-info/entry_points.txt +4 -0
  204. hexdag-0.5.0.dev1.dist-info/licenses/LICENSE +190 -0
  205. hexdag_plugins/.gitignore +43 -0
  206. hexdag_plugins/README.md +73 -0
  207. hexdag_plugins/__init__.py +1 -0
  208. hexdag_plugins/azure/LICENSE +21 -0
  209. hexdag_plugins/azure/README.md +414 -0
  210. hexdag_plugins/azure/__init__.py +21 -0
  211. hexdag_plugins/azure/azure_blob_adapter.py +450 -0
  212. hexdag_plugins/azure/azure_cosmos_adapter.py +383 -0
  213. hexdag_plugins/azure/azure_keyvault_adapter.py +314 -0
  214. hexdag_plugins/azure/azure_openai_adapter.py +415 -0
  215. hexdag_plugins/azure/pyproject.toml +107 -0
  216. hexdag_plugins/azure/tests/__init__.py +1 -0
  217. hexdag_plugins/azure/tests/test_azure_blob_adapter.py +350 -0
  218. hexdag_plugins/azure/tests/test_azure_cosmos_adapter.py +323 -0
  219. hexdag_plugins/azure/tests/test_azure_keyvault_adapter.py +330 -0
  220. hexdag_plugins/azure/tests/test_azure_openai_adapter.py +329 -0
  221. hexdag_plugins/hexdag_etl/README.md +168 -0
  222. hexdag_plugins/hexdag_etl/__init__.py +53 -0
  223. hexdag_plugins/hexdag_etl/examples/01_simple_pandas_transform.py +270 -0
  224. hexdag_plugins/hexdag_etl/examples/02_simple_pandas_only.py +149 -0
  225. hexdag_plugins/hexdag_etl/examples/03_file_io_pipeline.py +109 -0
  226. hexdag_plugins/hexdag_etl/examples/test_pandas_transform.py +84 -0
  227. hexdag_plugins/hexdag_etl/hexdag.toml +25 -0
  228. hexdag_plugins/hexdag_etl/hexdag_etl/__init__.py +48 -0
  229. hexdag_plugins/hexdag_etl/hexdag_etl/nodes/__init__.py +13 -0
  230. hexdag_plugins/hexdag_etl/hexdag_etl/nodes/api_extract.py +230 -0
  231. hexdag_plugins/hexdag_etl/hexdag_etl/nodes/base_node_factory.py +181 -0
  232. hexdag_plugins/hexdag_etl/hexdag_etl/nodes/file_io.py +415 -0
  233. hexdag_plugins/hexdag_etl/hexdag_etl/nodes/outlook.py +492 -0
  234. hexdag_plugins/hexdag_etl/hexdag_etl/nodes/pandas_transform.py +563 -0
  235. hexdag_plugins/hexdag_etl/hexdag_etl/nodes/sql_extract_load.py +112 -0
  236. hexdag_plugins/hexdag_etl/pyproject.toml +82 -0
  237. hexdag_plugins/hexdag_etl/test_transform.py +54 -0
  238. hexdag_plugins/hexdag_etl/tests/test_plugin_integration.py +62 -0
  239. hexdag_plugins/mysql_adapter/LICENSE +21 -0
  240. hexdag_plugins/mysql_adapter/README.md +224 -0
  241. hexdag_plugins/mysql_adapter/__init__.py +6 -0
  242. hexdag_plugins/mysql_adapter/mysql_adapter.py +408 -0
  243. hexdag_plugins/mysql_adapter/pyproject.toml +93 -0
  244. hexdag_plugins/mysql_adapter/tests/test_mysql_adapter.py +259 -0
  245. hexdag_plugins/storage/README.md +184 -0
  246. hexdag_plugins/storage/__init__.py +19 -0
  247. hexdag_plugins/storage/file/__init__.py +5 -0
  248. hexdag_plugins/storage/file/local.py +325 -0
  249. hexdag_plugins/storage/ports/__init__.py +5 -0
  250. hexdag_plugins/storage/ports/vector_store.py +236 -0
  251. hexdag_plugins/storage/sql/__init__.py +7 -0
  252. hexdag_plugins/storage/sql/base.py +187 -0
  253. hexdag_plugins/storage/sql/mysql.py +27 -0
  254. hexdag_plugins/storage/sql/postgresql.py +27 -0
  255. hexdag_plugins/storage/tests/__init__.py +1 -0
  256. hexdag_plugins/storage/tests/test_local_file_storage.py +161 -0
  257. hexdag_plugins/storage/tests/test_sql_adapters.py +212 -0
  258. hexdag_plugins/storage/vector/__init__.py +7 -0
  259. hexdag_plugins/storage/vector/chromadb.py +223 -0
  260. hexdag_plugins/storage/vector/in_memory.py +285 -0
  261. hexdag_plugins/storage/vector/pgvector.py +502 -0
@@ -0,0 +1,696 @@
1
+ """ReActAgentNode - Multi-step reasoning agent."""
2
+
3
+ import ast
4
+ import json
5
+ import time
6
+ from collections.abc import Callable
7
+ from dataclasses import dataclass
8
+ from typing import TYPE_CHECKING, Any, NotRequired, TypedDict
9
+
10
+ from pydantic import BaseModel, ConfigDict
11
+
12
+ from hexdag.builtin.adapters.unified_tool_router import UnifiedToolRouter
13
+ from hexdag.core.context import get_port, get_ports
14
+ from hexdag.core.domain.dag import NodeSpec
15
+ from hexdag.core.logging import get_logger
16
+ from hexdag.core.orchestration.prompt import PromptInput
17
+ from hexdag.core.orchestration.prompt.template import PromptTemplate
18
+ from hexdag.core.ports.tool_router import ToolRouter
19
+ from hexdag.core.protocols import to_dict
20
+
21
+ from .base_node_factory import BaseNodeFactory
22
+ from .llm_node import LLMNode
23
+ from .tool_utils import ToolCallFormat, ToolParser
24
+
25
+ logger = get_logger(__name__)
26
+
27
+ if TYPE_CHECKING:
28
+ from types import MappingProxyType
29
+
30
+
31
+ class PhaseContext(TypedDict):
32
+ """Context structure for phase transitions in agents.
33
+
34
+ Attributes
35
+ ----------
36
+ previous_phase : str, optional
37
+ The phase the agent is transitioning from
38
+ reason : str, optional
39
+ Explanation for why the phase change is occurring
40
+ carried_data : dict[str, Any], optional
41
+ Data to carry forward from the previous phase
42
+ target_output : str, optional
43
+ Expected output format or goal for the new phase
44
+ iteration : int, optional
45
+ Current iteration number if in a loop or retry scenario
46
+ metadata : dict[str, Any], optional
47
+ Additional metadata about the phase transition
48
+ """
49
+
50
+ previous_phase: NotRequired[str]
51
+ reason: NotRequired[str]
52
+ carried_data: NotRequired[dict[str, Any]]
53
+ target_output: NotRequired[str]
54
+ iteration: NotRequired[int]
55
+ metadata: NotRequired[dict[str, Any]]
56
+
57
+
58
+ class AgentState(BaseModel):
59
+ """Pydantic model for agent state - provides type safety and validation."""
60
+
61
+ # Original input data (preserved)
62
+ input_data: dict[str, Any] = {}
63
+
64
+ # Agent reasoning state
65
+ reasoning_steps: list[str] = []
66
+ tool_results: list[str] = []
67
+ tools_used: list[str] = []
68
+ current_phase: str = "main"
69
+ phase_history: list[str] = ["main"]
70
+ phase_contexts: dict[str, PhaseContext] = {} # Store typed context for each phase
71
+ step: int = 0
72
+ response: str = ""
73
+
74
+ # Loop iteration tracking
75
+ loop_iteration: int = 0
76
+
77
+ model_config = ConfigDict(extra="allow") # Allow additional fields from input mapping
78
+
79
+
80
+ @dataclass(frozen=True, slots=True)
81
+ class AgentConfig:
82
+ """Agent configuration for multi-step reasoning (legacy - kept for backward compatibility)."""
83
+
84
+ max_steps: int = 20
85
+ tool_call_style: ToolCallFormat = ToolCallFormat.MIXED
86
+
87
+
88
+ class Agent:
89
+ """Configuration for Agent Node.
90
+
91
+ Attributes
92
+ ----------
93
+ max_steps : int
94
+ Maximum number of reasoning steps (default: 20)
95
+ tool_call_style : ToolCallFormat
96
+ Format for tool calls - MIXED, FUNCTION_CALL, or JSON (default: MIXED)
97
+ """
98
+
99
+ max_steps: int = 20
100
+ tool_call_style: ToolCallFormat = ToolCallFormat.MIXED
101
+
102
+
103
+ class ReActAgentNode(BaseNodeFactory):
104
+ """Multi-step reasoning agent.
105
+
106
+ This agent:
107
+ 1. Uses loop control internally for iteration control
108
+ 2. Implements single-step reasoning logic
109
+ 3. Maintains clean agent interface for users
110
+ 4. Leverages proven loop control patterns
111
+ 5. Supports all agent features (tools, phases, events)
112
+
113
+ Architecture:
114
+ ```
115
+ Agent(input) -> Loop -> SingleStep -> Loop -> SingleStep -> ... -> Output
116
+ ```
117
+ """
118
+
119
+ def __init__(self, **kwargs: Any) -> None:
120
+ """Initialize with dependencies."""
121
+ self.llm_node = LLMNode()
122
+ self.tool_parser = ToolParser()
123
+
124
+ def __call__(
125
+ self,
126
+ name: str,
127
+ main_prompt: PromptInput,
128
+ continuation_prompts: dict[str, PromptInput] | None = None,
129
+ output_schema: dict[str, type] | type[BaseModel] | None = None,
130
+ config: AgentConfig | None = None,
131
+ deps: list[str] | None = None,
132
+ **kwargs: Any,
133
+ ) -> NodeSpec:
134
+ """Create a multi-step reasoning agent with internal loop control.
135
+
136
+ Args
137
+ ----
138
+ name: Agent name
139
+ main_prompt: Initial reasoning prompt
140
+ continuation_prompts: Phase-specific prompts
141
+ output_schema: Custom output schema for tool_end results
142
+ config: Agent configuration
143
+ deps: Dependencies
144
+ **kwargs: Additional parameters
145
+
146
+ Returns
147
+ -------
148
+ NodeSpec
149
+ A configured node specification for the agent
150
+ """
151
+ config = config or AgentConfig()
152
+
153
+ # Infer input schema from prompt
154
+ input_schema = self._infer_input_schema(main_prompt)
155
+
156
+ input_model = self.create_pydantic_model(f"{name}Input", input_schema)
157
+ if input_model is None:
158
+ input_model = type(f"{name}Input", (BaseModel,), {"__annotations__": {"input": str}})
159
+ output_model = self.create_pydantic_model(f"{name}Output", output_schema) or type(
160
+ f"{name}Output", (BaseModel,), {"__annotations__": {"output": str}}
161
+ )
162
+
163
+ agent_fn = self._create_agent_with_loop(
164
+ name, main_prompt, continuation_prompts or {}, output_model, config
165
+ )
166
+
167
+ # Use universal input mapping method
168
+ return self.create_node_with_mapping(
169
+ name=name,
170
+ wrapped_fn=agent_fn,
171
+ input_schema=input_schema,
172
+ output_schema=output_model,
173
+ deps=deps,
174
+ **kwargs,
175
+ )
176
+
177
+ def _infer_input_schema(self, prompt: PromptInput) -> dict[str, Any]:
178
+ """Infer input schema from prompt template.
179
+
180
+ Returns
181
+ -------
182
+ dict[str, Any]
183
+ Inferred input schema mapping
184
+ """
185
+ # Use the shared implementation from BaseNodeFactory
186
+ # AgentNode doesn't filter special params, so pass None
187
+ return BaseNodeFactory.infer_input_schema_from_template(prompt, special_params=None)
188
+
189
+ def _get_current_prompt(
190
+ self,
191
+ main_prompt: PromptInput,
192
+ continuation_prompts: dict[str, PromptInput],
193
+ current_phase: str,
194
+ ) -> PromptInput:
195
+ """Get the appropriate prompt for the current phase.
196
+
197
+ Returns
198
+ -------
199
+ PromptInput
200
+ The prompt to use for the current phase
201
+ """
202
+ if current_phase != "main" and current_phase in continuation_prompts:
203
+ return continuation_prompts[current_phase]
204
+ return main_prompt
205
+
206
+ def _create_agent_with_loop(
207
+ self,
208
+ name: str,
209
+ main_prompt: PromptInput,
210
+ continuation_prompts: dict[str, PromptInput],
211
+ output_model: type[BaseModel],
212
+ config: AgentConfig,
213
+ ) -> Callable[..., Any]:
214
+ """Create agent function with internal loop composition for multi-step iteration.
215
+
216
+ Returns
217
+ -------
218
+ Callable[..., Any]
219
+ Agent function with internal loop control
220
+ """
221
+
222
+ async def single_step_executor(input_data: Any) -> Any:
223
+ """Execute single reasoning step."""
224
+ from hexdag.core.context import get_port
225
+
226
+ ports: MappingProxyType[str, Any] | dict[Any, Any] = get_ports() or {}
227
+
228
+ state = self._initialize_or_update_state(input_data)
229
+
230
+ # Execute single reasoning step
231
+ updated_state = await self._execute_single_step(
232
+ state, name, main_prompt, continuation_prompts, config, dict(ports)
233
+ )
234
+
235
+ final_output = await self._check_for_final_output(
236
+ updated_state, output_model, get_port("event_manager")
237
+ )
238
+ if final_output is not None:
239
+ return final_output
240
+
241
+ # Return AgentState directly (Pydantic-first design)
242
+ return updated_state
243
+
244
+ # Define success condition using loop concepts
245
+ def success_condition(result: Any) -> bool:
246
+ """Check if agent should stop iterating."""
247
+ # Stop if we got the final structured output (not AgentState)
248
+ if not isinstance(result, AgentState):
249
+ return True
250
+
251
+ # Stop if we reached max steps
252
+ if result.step >= config.max_steps:
253
+ return True
254
+
255
+ # Stop if tool_end was detected
256
+ return "tool_end" in result.response.lower()
257
+
258
+ async def agent_with_internal_loop(input_data: Any) -> Any:
259
+ """Agent executor with internal loop control."""
260
+ node_logger = logger.bind(node=name, node_type="agent_node")
261
+ start_time = time.perf_counter()
262
+
263
+ # Log agent start
264
+ node_logger.info(
265
+ "Starting agent",
266
+ max_steps=config.max_steps,
267
+ tool_call_style=config.tool_call_style.value,
268
+ )
269
+
270
+ # Start with initial input
271
+ current_result = input_data
272
+
273
+ # Run the loop until success condition is met or max iterations reached
274
+ for step_num in range(config.max_steps):
275
+ # Log step start
276
+ node_logger.debug(
277
+ "Agent step starting",
278
+ step=step_num + 1,
279
+ max_steps=config.max_steps,
280
+ )
281
+
282
+ # Execute single step
283
+ step_result = await single_step_executor(current_result)
284
+
285
+ # If not AgentState, it's the final output
286
+ if not isinstance(step_result, AgentState):
287
+ duration_ms = (time.perf_counter() - start_time) * 1000
288
+ node_logger.info(
289
+ "Agent completed with direct output",
290
+ total_steps=step_num + 1,
291
+ duration_ms=f"{duration_ms:.2f}",
292
+ output_type=type(step_result).__name__,
293
+ )
294
+ return step_result
295
+
296
+ # Log step completion with state info
297
+ node_logger.debug(
298
+ "Agent step completed",
299
+ step=step_num + 1,
300
+ phase=step_result.current_phase,
301
+ tools_used_count=len(step_result.tools_used),
302
+ )
303
+
304
+ # Check success condition
305
+ if success_condition(step_result):
306
+ final_output = await self._check_for_final_output(
307
+ step_result,
308
+ output_model,
309
+ get_port("event_manager"),
310
+ )
311
+ if final_output is not None:
312
+ duration_ms = (time.perf_counter() - start_time) * 1000
313
+ node_logger.info(
314
+ "Agent completed",
315
+ total_steps=step_num + 1,
316
+ tools_used=step_result.tools_used,
317
+ phases=step_result.phase_history,
318
+ duration_ms=f"{duration_ms:.2f}",
319
+ )
320
+ return final_output
321
+ return step_result
322
+
323
+ # Continue with next iteration (pass AgentState directly)
324
+ current_result = step_result
325
+
326
+ # If we reach here, max steps reached
327
+ duration_ms = (time.perf_counter() - start_time) * 1000
328
+ node_logger.warning(
329
+ "Agent reached max steps",
330
+ max_steps=config.max_steps,
331
+ duration_ms=f"{duration_ms:.2f}",
332
+ )
333
+ return current_result
334
+
335
+ return agent_with_internal_loop
336
+
337
+ def _initialize_or_update_state(self, input_data: Any) -> AgentState:
338
+ """Initialize new state or update existing state from loop iteration.
339
+
340
+ Returns
341
+ -------
342
+ AgentState
343
+ Initialized or updated agent state
344
+ """
345
+ # Case 1: Already AgentState (from previous iteration) - return as-is
346
+ if isinstance(input_data, AgentState):
347
+ return input_data
348
+
349
+ # Case 2: Dict with AgentState fields (legacy/backward compatibility)
350
+ if isinstance(input_data, dict) and "reasoning_steps" in input_data:
351
+ state: AgentState = AgentState.model_validate(input_data)
352
+ return state
353
+
354
+ # Case 3: Fresh input (first iteration) - wrap in AgentState
355
+ try:
356
+ raw_input = to_dict(input_data)
357
+ except TypeError:
358
+ # Fallback for non-dict types
359
+ raw_input = {"input": str(input_data)}
360
+
361
+ return AgentState(input_data=raw_input)
362
+
363
+ def _enhance_prompt_with_tools(
364
+ self, prompt: PromptInput, tool_router: ToolRouter | None, config: AgentConfig
365
+ ) -> PromptInput:
366
+ """Add tool instructions to the prompt.
367
+
368
+ Returns
369
+ -------
370
+ PromptInput
371
+ Enhanced prompt with tool instructions
372
+ """
373
+ if not tool_router:
374
+ return prompt
375
+
376
+ if isinstance(prompt, str):
377
+ prompt = PromptTemplate(prompt)
378
+
379
+ tool_instructions = self._build_tool_instructions(tool_router, config)
380
+
381
+ # Use the template's enhance method
382
+ return prompt + tool_instructions
383
+
384
+ def _build_tool_instructions(self, tool_router: ToolRouter, config: AgentConfig) -> str:
385
+ """Build tool usage instructions based on the configured format.
386
+
387
+ Returns
388
+ -------
389
+ str
390
+ Tool usage instructions text
391
+ """
392
+ tool_schemas = tool_router.get_all_tool_schemas()
393
+ if not tool_schemas:
394
+ return "\n## No tools available"
395
+
396
+ tool_list = []
397
+ for name, schema in tool_schemas.items():
398
+ params = ", ".join(p["name"] for p in schema.get("parameters", []))
399
+ tool_list.append(f"- {name}({params}): {schema.get('description', 'No description')}")
400
+
401
+ tools_text = "\n".join(tool_list)
402
+
403
+ # Generate format-specific usage guidelines
404
+ usage_guidelines = self._get_format_specific_guidelines(config.tool_call_style)
405
+
406
+ return f"""
407
+ ## Available Tools
408
+ {tools_text}
409
+
410
+ ## Usage Guidelines
411
+ {usage_guidelines}
412
+ """
413
+
414
+ def _get_format_specific_guidelines(self, format_style: ToolCallFormat) -> str:
415
+ """Generate format-specific tool calling guidelines.
416
+
417
+ Returns
418
+ -------
419
+ str
420
+ Format-specific guidelines text
421
+ """
422
+ if format_style == ToolCallFormat.FUNCTION_CALL:
423
+ return """- Call ONE tool at a time: INVOKE_TOOL: tool_name(param='value')
424
+ - For final answer and structured output: INVOKE_TOOL: tool_end(field1='value1', field2='value2')
425
+ - For phase change: INVOKE_TOOL: change_phase(phase='new_phase', reason='why changing',
426
+ carried_data={'key': 'value'})"""
427
+
428
+ if format_style == ToolCallFormat.JSON:
429
+ return (
430
+ """- Call ONE tool at a time: INVOKE_TOOL: """
431
+ """{"tool": "tool_name", "params": {"param": "value"}}\n"""
432
+ """- For final answer and structured output: INVOKE_TOOL: """
433
+ """{"tool": "tool_end", "params": {"field1": "value1", "field2": "value2"}}\n"""
434
+ """- For phase change: INVOKE_TOOL: """
435
+ """{"tool": "change_phase", "params": {"phase": "new_phase", "reason": "why",
436
+ "carried_data": {"key": "val"}}}"""
437
+ )
438
+
439
+ # ToolCallFormat.MIXED
440
+ return """- Call ONE tool at a time using either format:
441
+ - Function style: INVOKE_TOOL: tool_name(param='value')
442
+ - JSON style: INVOKE_TOOL: {"tool": "tool_name", "params": {"param": "value"}}
443
+ - For final answer and structured output:
444
+ - Function: INVOKE_TOOL: tool_end(field1='value1', field2='value2')
445
+ - JSON: INVOKE_TOOL: {"tool": "tool_end", "params": {"field1": "value1", "field2": "value2"}}
446
+ - For phase change:
447
+ - Function: INVOKE_TOOL: change_phase(phase='new_phase', reason='why',
448
+ carried_data={'key': 'val'})
449
+ - JSON: INVOKE_TOOL: {"tool": "change_phase", "params": {"phase": "new_phase",
450
+ "reason": "why", "carried_data": {"key": "val"}}}"""
451
+
452
+ async def _get_llm_response(
453
+ self, prompt: PromptInput, llm_input: dict[str, Any], ports: dict[str, Any], node_name: str
454
+ ) -> str:
455
+ """Get response from LLM.
456
+
457
+ Returns
458
+ -------
459
+ str
460
+ LLM response text
461
+ """
462
+ # Ensure we have a proper template (not string)
463
+ if isinstance(prompt, str):
464
+ prompt = PromptTemplate(prompt)
465
+
466
+ llm_node_spec = self.llm_node.from_template(node_name, template=prompt)
467
+
468
+ # Execute LLM with the prepared input (no ports passed - uses ExecutionContext)
469
+ return await llm_node_spec.fn(llm_input) # type: ignore[no-any-return]
470
+
471
+ async def _execute_single_step(
472
+ self,
473
+ state: AgentState,
474
+ name: str,
475
+ main_prompt: PromptInput,
476
+ continuation_prompts: dict[str, PromptInput],
477
+ config: AgentConfig,
478
+ ports: dict[str, Any],
479
+ ) -> AgentState:
480
+ """Execute a single reasoning step.
481
+
482
+ Returns
483
+ -------
484
+ AgentState
485
+ Updated agent state after step execution
486
+ """
487
+ event_manager = ports.get("event_manager")
488
+ tool_router = ports.get("tool_router", UnifiedToolRouter())
489
+
490
+ current_step = max(state.loop_iteration, state.step) + 1
491
+ node_step_name = f"{name}_step_{current_step}"
492
+
493
+ current_prompt = self._get_current_prompt(
494
+ main_prompt, continuation_prompts, state.current_phase
495
+ )
496
+
497
+ # Enhance prompt with tools
498
+ enhanced_prompt = self._enhance_prompt_with_tools(current_prompt, tool_router, config)
499
+
500
+ current_phase_context = state.phase_contexts.get(state.current_phase, {})
501
+
502
+ # Build LLM input - only convert to dict when needed for template
503
+ # Merge state fields with template-specific overrides
504
+ llm_input = {
505
+ **state.model_dump(), # Convert only once, at template boundary
506
+ **state.input_data,
507
+ "reasoning_so_far": "\n".join(state.reasoning_steps) or "Starting reasoning...",
508
+ "phase_context": current_phase_context,
509
+ "phase_reason": current_phase_context.get("reason", ""),
510
+ "phase_target": current_phase_context.get("target_output", ""),
511
+ }
512
+
513
+ response = await self._get_llm_response(enhanced_prompt, llm_input, ports, node_step_name)
514
+
515
+ # Process tools and phase changes
516
+ await self._process_tools_and_phases(
517
+ response, state, tool_router, continuation_prompts, config, event_manager
518
+ )
519
+
520
+ state.reasoning_steps.append(f"Step {current_step}: {response}")
521
+ state.response = response
522
+ state.step = current_step
523
+
524
+ return state
525
+
526
+ def _should_terminate(self, response: str) -> bool:
527
+ """Check if agent should terminate.
528
+
529
+ Returns
530
+ -------
531
+ bool
532
+ True if agent should terminate execution
533
+ """
534
+ return "tool_end" in response.lower() or "Tool_END" in response
535
+
536
+ async def _process_tools_and_phases(
537
+ self,
538
+ response: str,
539
+ state: AgentState,
540
+ tool_router: ToolRouter | None,
541
+ continuation_prompts: dict[str, PromptInput],
542
+ config: AgentConfig,
543
+ event_manager: Any,
544
+ ) -> None:
545
+ """Process tool calls and phase changes."""
546
+ if not tool_router:
547
+ return
548
+
549
+ # Parse tool calls
550
+ tool_calls = self.tool_parser.parse_tool_calls(response, format=config.tool_call_style)
551
+
552
+ if tool_calls:
553
+ logger.debug(
554
+ "Parsed tool calls",
555
+ tool_count=len(tool_calls),
556
+ tools=[tc.name for tc in tool_calls],
557
+ )
558
+
559
+ for tool_call in tool_calls:
560
+ try:
561
+ # Log tool execution
562
+ logger.debug(
563
+ "Executing tool",
564
+ tool_name=tool_call.name,
565
+ params_preview=str(tool_call.params)[:100],
566
+ )
567
+
568
+ # Execute tool
569
+ result = await tool_router.acall_tool(tool_call.name, tool_call.params)
570
+
571
+ state.tool_results.append(f"{tool_call.name}: {result}")
572
+ state.tools_used.append(tool_call.name)
573
+
574
+ if tool_call.name == "change_phase" and isinstance(result, dict):
575
+ new_phase = result.get("new_phase")
576
+ context = result.get("context", {})
577
+
578
+ if new_phase and new_phase in continuation_prompts:
579
+ old_phase = state.current_phase
580
+
581
+ if "previous_phase" not in context:
582
+ context["previous_phase"] = state.current_phase
583
+
584
+ state.phase_contexts[new_phase] = context
585
+
586
+ state.current_phase = new_phase
587
+ state.phase_history.append(new_phase)
588
+
589
+ # Log phase transition
590
+ logger.info(
591
+ "Phase transition",
592
+ from_phase=old_phase,
593
+ to_phase=new_phase,
594
+ reason=context.get("reason", ""),
595
+ )
596
+
597
+ # If there's carried_data, merge it into state.input_data
598
+ if "carried_data" in context and isinstance(context["carried_data"], dict):
599
+ state.input_data.update(context["carried_data"])
600
+
601
+ except Exception as e:
602
+ error_msg = f"{tool_call.name}: Error - {e}"
603
+ state.tool_results.append(error_msg)
604
+ logger.warning(
605
+ "Tool execution failed",
606
+ tool_name=tool_call.name,
607
+ error=str(e),
608
+ )
609
+
610
+ def _parse_tool_end_result(self, tool_result: str) -> dict[str, Any] | None:
611
+ """Parse tool_end result string into structured data.
612
+
613
+ Args
614
+ ----
615
+ tool_result : str
616
+ Tool result string in format "tool_end: {data}"
617
+
618
+ Returns
619
+ -------
620
+ dict[str, Any] | None
621
+ Parsed data dictionary or None if parsing fails
622
+ """
623
+ if not tool_result or not tool_result.startswith("tool_end:"):
624
+ return None
625
+
626
+ try:
627
+ result_str = tool_result.split(":", 1)[1].strip()
628
+ result_data = ast.literal_eval(result_str)
629
+
630
+ if isinstance(result_data, dict):
631
+ return result_data
632
+ return None
633
+ except (json.JSONDecodeError, SyntaxError, ValueError, IndexError):
634
+ # Failed to parse - return None to skip this result
635
+ # IndexError: split failed (malformed tool_end output)
636
+ return None
637
+
638
+ async def _emit_agent_metadata(self, state: AgentState, event_manager: Any) -> None:
639
+ """Emit agent metadata trace event.
640
+
641
+ Args
642
+ ----
643
+ state : AgentState
644
+ Current agent state
645
+ event_manager : Any
646
+ Event manager instance
647
+ """
648
+ if event_manager and hasattr(event_manager, "add_trace"):
649
+ await event_manager.add_trace(
650
+ "agent_metadata",
651
+ {
652
+ "reasoning_steps": state.reasoning_steps,
653
+ "tools_used": list(set(state.tools_used)),
654
+ "reasoning_phases": state.phase_history,
655
+ "total_steps": state.step,
656
+ },
657
+ )
658
+
659
+ async def _check_for_final_output(
660
+ self,
661
+ state: AgentState,
662
+ output_model: type[BaseModel],
663
+ event_manager: Any,
664
+ ) -> Any | None:
665
+ """Check if we have a final output from tool_end calls.
666
+
667
+ Returns
668
+ -------
669
+ Any | None
670
+ Final output model instance or None if not found
671
+ """
672
+ # Check for tool_end calls with structured output
673
+ for tool_result in reversed(state.tool_results):
674
+ parsed_data = self._parse_tool_end_result(tool_result)
675
+
676
+ if parsed_data is not None:
677
+ try:
678
+ # Emit metadata before returning final result
679
+ await self._emit_agent_metadata(state, event_manager)
680
+
681
+ return output_model.model_validate(parsed_data)
682
+
683
+ except (ValueError, TypeError) as e:
684
+ # Validation failed - try next tool_end result
685
+ logger.debug(
686
+ "Failed to validate tool_end result",
687
+ output_model=output_model.__name__,
688
+ error=str(e),
689
+ )
690
+ continue # Skip this tool result and try the next one
691
+
692
+ return None
693
+
694
+
695
+ # Backward compatibility alias
696
+ ReasoningAgentNode = ReActAgentNode