machinaos 0.0.1 → 0.0.7

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 (422) hide show
  1. package/.env.template +71 -71
  2. package/LICENSE +21 -21
  3. package/README.md +163 -87
  4. package/bin/cli.js +62 -106
  5. package/client/.dockerignore +45 -45
  6. package/client/Dockerfile +68 -68
  7. package/client/dist/assets/index-DFSC53FP.css +1 -0
  8. package/client/dist/assets/index-fJ-1gTf5.js +613 -0
  9. package/client/dist/index.html +14 -0
  10. package/client/eslint.config.js +34 -16
  11. package/client/nginx.conf +66 -66
  12. package/client/package.json +61 -48
  13. package/client/src/App.tsx +27 -27
  14. package/client/src/Dashboard.tsx +1200 -1172
  15. package/client/src/ParameterPanel.tsx +302 -300
  16. package/client/src/components/AIAgentNode.tsx +315 -321
  17. package/client/src/components/APIKeyValidator.tsx +117 -117
  18. package/client/src/components/ClaudeChatModelNode.tsx +17 -17
  19. package/client/src/components/CredentialsModal.tsx +1200 -306
  20. package/client/src/components/GeminiChatModelNode.tsx +17 -17
  21. package/client/src/components/GenericNode.tsx +356 -356
  22. package/client/src/components/LocationParameterPanel.tsx +153 -153
  23. package/client/src/components/ModelNode.tsx +285 -285
  24. package/client/src/components/OpenAIChatModelNode.tsx +17 -17
  25. package/client/src/components/OutputPanel.tsx +470 -470
  26. package/client/src/components/ParameterRenderer.tsx +1873 -1873
  27. package/client/src/components/SkillEditorModal.tsx +3 -3
  28. package/client/src/components/SquareNode.tsx +812 -796
  29. package/client/src/components/ToolkitNode.tsx +365 -365
  30. package/client/src/components/auth/LoginPage.tsx +247 -247
  31. package/client/src/components/auth/ProtectedRoute.tsx +59 -59
  32. package/client/src/components/base/BaseChatModelNode.tsx +270 -270
  33. package/client/src/components/icons/AIProviderIcons.tsx +50 -50
  34. package/client/src/components/maps/GoogleMapsPicker.tsx +136 -136
  35. package/client/src/components/maps/MapsPreviewPanel.tsx +109 -109
  36. package/client/src/components/maps/index.ts +25 -25
  37. package/client/src/components/parameterPanel/InputSection.tsx +1094 -1094
  38. package/client/src/components/parameterPanel/LocationPanelLayout.tsx +64 -64
  39. package/client/src/components/parameterPanel/MapsSection.tsx +91 -91
  40. package/client/src/components/parameterPanel/MiddleSection.tsx +867 -571
  41. package/client/src/components/parameterPanel/OutputSection.tsx +80 -80
  42. package/client/src/components/parameterPanel/ParameterPanelLayout.tsx +81 -81
  43. package/client/src/components/parameterPanel/ToolSchemaEditor.tsx +436 -436
  44. package/client/src/components/parameterPanel/index.ts +41 -41
  45. package/client/src/components/shared/DataPanel.tsx +142 -142
  46. package/client/src/components/shared/JSONTreeRenderer.tsx +105 -105
  47. package/client/src/components/ui/AIResultModal.tsx +203 -203
  48. package/client/src/components/ui/ApiKeyInput.tsx +93 -0
  49. package/client/src/components/ui/CodeEditor.tsx +81 -81
  50. package/client/src/components/ui/CollapsibleSection.tsx +87 -87
  51. package/client/src/components/ui/ComponentItem.tsx +153 -153
  52. package/client/src/components/ui/ComponentPalette.tsx +320 -320
  53. package/client/src/components/ui/ConsolePanel.tsx +151 -43
  54. package/client/src/components/ui/ErrorBoundary.tsx +195 -195
  55. package/client/src/components/ui/InputNodesPanel.tsx +203 -203
  56. package/client/src/components/ui/MapSelector.tsx +313 -313
  57. package/client/src/components/ui/Modal.tsx +151 -148
  58. package/client/src/components/ui/NodeOutputPanel.tsx +1150 -1150
  59. package/client/src/components/ui/OutputDisplayPanel.tsx +381 -381
  60. package/client/src/components/ui/QRCodeDisplay.tsx +182 -0
  61. package/client/src/components/ui/TopToolbar.tsx +736 -736
  62. package/client/src/components/ui/WorkflowSidebar.tsx +293 -293
  63. package/client/src/config/antdTheme.ts +186 -186
  64. package/client/src/contexts/AuthContext.tsx +221 -221
  65. package/client/src/contexts/ThemeContext.tsx +42 -42
  66. package/client/src/contexts/WebSocketContext.tsx +2144 -1971
  67. package/client/src/factories/baseChatModelFactory.ts +255 -255
  68. package/client/src/hooks/useAndroidOperations.ts +118 -164
  69. package/client/src/hooks/useApiKeyValidation.ts +106 -106
  70. package/client/src/hooks/useApiKeys.ts +238 -238
  71. package/client/src/hooks/useAppTheme.ts +17 -17
  72. package/client/src/hooks/useComponentPalette.ts +50 -50
  73. package/client/src/hooks/useDragAndDrop.ts +123 -123
  74. package/client/src/hooks/useDragVariable.ts +88 -88
  75. package/client/src/hooks/useExecution.ts +319 -313
  76. package/client/src/hooks/useParameterPanel.ts +176 -176
  77. package/client/src/hooks/useReactFlowNodes.ts +188 -188
  78. package/client/src/hooks/useToolSchema.ts +209 -209
  79. package/client/src/hooks/useWhatsApp.ts +196 -196
  80. package/client/src/hooks/useWorkflowManagement.ts +45 -45
  81. package/client/src/index.css +314 -314
  82. package/client/src/nodeDefinitions/aiAgentNodes.ts +335 -335
  83. package/client/src/nodeDefinitions/aiModelNodes.ts +340 -340
  84. package/client/src/nodeDefinitions/androidServiceNodes.ts +383 -383
  85. package/client/src/nodeDefinitions/chatNodes.ts +135 -135
  86. package/client/src/nodeDefinitions/codeNodes.ts +54 -54
  87. package/client/src/nodeDefinitions/index.ts +14 -14
  88. package/client/src/nodeDefinitions/locationNodes.ts +462 -462
  89. package/client/src/nodeDefinitions/schedulerNodes.ts +220 -220
  90. package/client/src/nodeDefinitions/skillNodes.ts +17 -5
  91. package/client/src/nodeDefinitions/utilityNodes.ts +284 -284
  92. package/client/src/nodeDefinitions/whatsappNodes.ts +821 -865
  93. package/client/src/nodeDefinitions.ts +101 -103
  94. package/client/src/services/dynamicParameterService.ts +95 -95
  95. package/client/src/services/execution/aiAgentExecutionService.ts +34 -34
  96. package/client/src/services/executionService.ts +227 -231
  97. package/client/src/services/workflowApi.ts +91 -91
  98. package/client/src/store/useAppStore.ts +578 -581
  99. package/client/src/styles/theme.ts +513 -508
  100. package/client/src/styles/zIndex.ts +16 -16
  101. package/client/src/types/ComponentTypes.ts +38 -38
  102. package/client/src/types/INodeProperties.ts +287 -287
  103. package/client/src/types/NodeTypes.ts +27 -27
  104. package/client/src/utils/formatters.ts +32 -32
  105. package/client/src/utils/googleMapsLoader.ts +139 -139
  106. package/client/src/utils/locationUtils.ts +84 -84
  107. package/client/src/utils/nodeUtils.ts +30 -30
  108. package/client/src/utils/workflow.ts +29 -29
  109. package/client/src/vite-env.d.ts +12 -12
  110. package/client/tailwind.config.js +59 -59
  111. package/client/tsconfig.json +25 -25
  112. package/client/vite.config.js +35 -35
  113. package/install.ps1 +308 -0
  114. package/install.sh +343 -0
  115. package/package.json +81 -70
  116. package/scripts/build.js +174 -51
  117. package/scripts/clean.js +40 -40
  118. package/scripts/start.js +234 -210
  119. package/scripts/stop.js +301 -325
  120. package/server/.dockerignore +44 -44
  121. package/server/Dockerfile +45 -45
  122. package/server/constants.py +244 -249
  123. package/server/core/cache.py +460 -460
  124. package/server/core/config.py +127 -127
  125. package/server/core/container.py +98 -98
  126. package/server/core/database.py +1296 -1210
  127. package/server/core/logging.py +313 -313
  128. package/server/main.py +288 -288
  129. package/server/middleware/__init__.py +5 -5
  130. package/server/middleware/auth.py +89 -89
  131. package/server/models/auth.py +52 -52
  132. package/server/models/cache.py +24 -24
  133. package/server/models/database.py +235 -210
  134. package/server/models/nodes.py +435 -455
  135. package/server/pyproject.toml +75 -72
  136. package/server/requirements.txt +83 -83
  137. package/server/routers/android.py +294 -294
  138. package/server/routers/auth.py +203 -203
  139. package/server/routers/database.py +150 -150
  140. package/server/routers/maps.py +141 -141
  141. package/server/routers/nodejs_compat.py +288 -288
  142. package/server/routers/webhook.py +90 -90
  143. package/server/routers/websocket.py +2239 -2127
  144. package/server/routers/whatsapp.py +761 -761
  145. package/server/routers/workflow.py +199 -199
  146. package/server/services/ai.py +2444 -2414
  147. package/server/services/android_service.py +588 -588
  148. package/server/services/auth.py +130 -130
  149. package/server/services/chat_client.py +160 -160
  150. package/server/services/deployment/manager.py +706 -706
  151. package/server/services/event_waiter.py +675 -785
  152. package/server/services/execution/executor.py +1351 -1351
  153. package/server/services/execution/models.py +1 -1
  154. package/server/services/handlers/__init__.py +122 -126
  155. package/server/services/handlers/ai.py +390 -355
  156. package/server/services/handlers/android.py +69 -260
  157. package/server/services/handlers/code.py +278 -278
  158. package/server/services/handlers/http.py +193 -193
  159. package/server/services/handlers/tools.py +146 -32
  160. package/server/services/handlers/triggers.py +107 -107
  161. package/server/services/handlers/utility.py +822 -822
  162. package/server/services/handlers/whatsapp.py +423 -476
  163. package/server/services/maps.py +288 -288
  164. package/server/services/memory_store.py +103 -103
  165. package/server/services/node_executor.py +372 -375
  166. package/server/services/scheduler.py +155 -155
  167. package/server/services/skill_loader.py +1 -1
  168. package/server/services/status_broadcaster.py +834 -826
  169. package/server/services/temporal/__init__.py +23 -23
  170. package/server/services/temporal/activities.py +344 -344
  171. package/server/services/temporal/client.py +76 -76
  172. package/server/services/temporal/executor.py +147 -147
  173. package/server/services/temporal/worker.py +251 -251
  174. package/server/services/temporal/workflow.py +355 -355
  175. package/server/services/temporal/ws_client.py +236 -236
  176. package/server/services/text.py +110 -110
  177. package/server/services/user_auth.py +172 -172
  178. package/server/services/websocket_client.py +29 -29
  179. package/server/services/workflow.py +597 -597
  180. package/server/skills/android-skill/SKILL.md +4 -4
  181. package/server/skills/code-skill/SKILL.md +123 -89
  182. package/server/skills/maps-skill/SKILL.md +3 -3
  183. package/server/skills/memory-skill/SKILL.md +1 -1
  184. package/server/skills/web-search-skill/SKILL.md +154 -0
  185. package/server/skills/whatsapp-skill/SKILL.md +3 -3
  186. package/server/uv.lock +461 -100
  187. package/server/whatsapp-rpc/.dockerignore +30 -30
  188. package/server/whatsapp-rpc/Dockerfile +44 -44
  189. package/server/whatsapp-rpc/Dockerfile.web +17 -17
  190. package/server/whatsapp-rpc/README.md +139 -139
  191. package/server/whatsapp-rpc/bin/whatsapp-rpc-server +0 -0
  192. package/server/whatsapp-rpc/cli.js +95 -95
  193. package/server/whatsapp-rpc/configs/config.yaml +6 -6
  194. package/server/whatsapp-rpc/docker-compose.yml +35 -35
  195. package/server/whatsapp-rpc/docs/API.md +410 -410
  196. package/server/whatsapp-rpc/node_modules/.package-lock.json +259 -0
  197. package/server/whatsapp-rpc/node_modules/chalk/license +9 -0
  198. package/server/whatsapp-rpc/node_modules/chalk/package.json +83 -0
  199. package/server/whatsapp-rpc/node_modules/chalk/readme.md +297 -0
  200. package/server/whatsapp-rpc/node_modules/chalk/source/index.d.ts +325 -0
  201. package/server/whatsapp-rpc/node_modules/chalk/source/index.js +225 -0
  202. package/server/whatsapp-rpc/node_modules/chalk/source/utilities.js +33 -0
  203. package/server/whatsapp-rpc/node_modules/chalk/source/vendor/ansi-styles/index.d.ts +236 -0
  204. package/server/whatsapp-rpc/node_modules/chalk/source/vendor/ansi-styles/index.js +223 -0
  205. package/server/whatsapp-rpc/node_modules/chalk/source/vendor/supports-color/browser.d.ts +1 -0
  206. package/server/whatsapp-rpc/node_modules/chalk/source/vendor/supports-color/browser.js +34 -0
  207. package/server/whatsapp-rpc/node_modules/chalk/source/vendor/supports-color/index.d.ts +55 -0
  208. package/server/whatsapp-rpc/node_modules/chalk/source/vendor/supports-color/index.js +190 -0
  209. package/server/whatsapp-rpc/node_modules/commander/LICENSE +22 -0
  210. package/server/whatsapp-rpc/node_modules/commander/Readme.md +1148 -0
  211. package/server/whatsapp-rpc/node_modules/commander/esm.mjs +16 -0
  212. package/server/whatsapp-rpc/node_modules/commander/index.js +26 -0
  213. package/server/whatsapp-rpc/node_modules/commander/lib/argument.js +145 -0
  214. package/server/whatsapp-rpc/node_modules/commander/lib/command.js +2179 -0
  215. package/server/whatsapp-rpc/node_modules/commander/lib/error.js +43 -0
  216. package/server/whatsapp-rpc/node_modules/commander/lib/help.js +462 -0
  217. package/server/whatsapp-rpc/node_modules/commander/lib/option.js +329 -0
  218. package/server/whatsapp-rpc/node_modules/commander/lib/suggestSimilar.js +100 -0
  219. package/server/whatsapp-rpc/node_modules/commander/package-support.json +16 -0
  220. package/server/whatsapp-rpc/node_modules/commander/package.json +80 -0
  221. package/server/whatsapp-rpc/node_modules/commander/typings/esm.d.mts +3 -0
  222. package/server/whatsapp-rpc/node_modules/commander/typings/index.d.ts +884 -0
  223. package/server/whatsapp-rpc/node_modules/cross-spawn/LICENSE +21 -0
  224. package/server/whatsapp-rpc/node_modules/cross-spawn/README.md +89 -0
  225. package/server/whatsapp-rpc/node_modules/cross-spawn/index.js +39 -0
  226. package/server/whatsapp-rpc/node_modules/cross-spawn/lib/enoent.js +59 -0
  227. package/server/whatsapp-rpc/node_modules/cross-spawn/lib/parse.js +91 -0
  228. package/server/whatsapp-rpc/node_modules/cross-spawn/lib/util/escape.js +47 -0
  229. package/server/whatsapp-rpc/node_modules/cross-spawn/lib/util/readShebang.js +23 -0
  230. package/server/whatsapp-rpc/node_modules/cross-spawn/lib/util/resolveCommand.js +52 -0
  231. package/server/whatsapp-rpc/node_modules/cross-spawn/package.json +73 -0
  232. package/server/whatsapp-rpc/node_modules/execa/index.d.ts +955 -0
  233. package/server/whatsapp-rpc/node_modules/execa/index.js +309 -0
  234. package/server/whatsapp-rpc/node_modules/execa/lib/command.js +119 -0
  235. package/server/whatsapp-rpc/node_modules/execa/lib/error.js +87 -0
  236. package/server/whatsapp-rpc/node_modules/execa/lib/kill.js +102 -0
  237. package/server/whatsapp-rpc/node_modules/execa/lib/pipe.js +42 -0
  238. package/server/whatsapp-rpc/node_modules/execa/lib/promise.js +36 -0
  239. package/server/whatsapp-rpc/node_modules/execa/lib/stdio.js +49 -0
  240. package/server/whatsapp-rpc/node_modules/execa/lib/stream.js +133 -0
  241. package/server/whatsapp-rpc/node_modules/execa/lib/verbose.js +19 -0
  242. package/server/whatsapp-rpc/node_modules/execa/license +9 -0
  243. package/server/whatsapp-rpc/node_modules/execa/package.json +90 -0
  244. package/server/whatsapp-rpc/node_modules/execa/readme.md +822 -0
  245. package/server/whatsapp-rpc/node_modules/get-stream/license +9 -0
  246. package/server/whatsapp-rpc/node_modules/get-stream/package.json +53 -0
  247. package/server/whatsapp-rpc/node_modules/get-stream/readme.md +291 -0
  248. package/server/whatsapp-rpc/node_modules/get-stream/source/array-buffer.js +84 -0
  249. package/server/whatsapp-rpc/node_modules/get-stream/source/array.js +32 -0
  250. package/server/whatsapp-rpc/node_modules/get-stream/source/buffer.js +20 -0
  251. package/server/whatsapp-rpc/node_modules/get-stream/source/contents.js +101 -0
  252. package/server/whatsapp-rpc/node_modules/get-stream/source/index.d.ts +119 -0
  253. package/server/whatsapp-rpc/node_modules/get-stream/source/index.js +5 -0
  254. package/server/whatsapp-rpc/node_modules/get-stream/source/string.js +36 -0
  255. package/server/whatsapp-rpc/node_modules/get-stream/source/utils.js +11 -0
  256. package/server/whatsapp-rpc/node_modules/get-them-args/LICENSE +21 -0
  257. package/server/whatsapp-rpc/node_modules/get-them-args/README.md +95 -0
  258. package/server/whatsapp-rpc/node_modules/get-them-args/index.js +97 -0
  259. package/server/whatsapp-rpc/node_modules/get-them-args/package.json +36 -0
  260. package/server/whatsapp-rpc/node_modules/human-signals/LICENSE +201 -0
  261. package/server/whatsapp-rpc/node_modules/human-signals/README.md +168 -0
  262. package/server/whatsapp-rpc/node_modules/human-signals/build/src/core.js +273 -0
  263. package/server/whatsapp-rpc/node_modules/human-signals/build/src/main.d.ts +73 -0
  264. package/server/whatsapp-rpc/node_modules/human-signals/build/src/main.js +70 -0
  265. package/server/whatsapp-rpc/node_modules/human-signals/build/src/realtime.js +16 -0
  266. package/server/whatsapp-rpc/node_modules/human-signals/build/src/signals.js +34 -0
  267. package/server/whatsapp-rpc/node_modules/human-signals/package.json +61 -0
  268. package/server/whatsapp-rpc/node_modules/is-stream/index.d.ts +81 -0
  269. package/server/whatsapp-rpc/node_modules/is-stream/index.js +29 -0
  270. package/server/whatsapp-rpc/node_modules/is-stream/license +9 -0
  271. package/server/whatsapp-rpc/node_modules/is-stream/package.json +44 -0
  272. package/server/whatsapp-rpc/node_modules/is-stream/readme.md +60 -0
  273. package/server/whatsapp-rpc/node_modules/isexe/LICENSE +15 -0
  274. package/server/whatsapp-rpc/node_modules/isexe/README.md +51 -0
  275. package/server/whatsapp-rpc/node_modules/isexe/index.js +57 -0
  276. package/server/whatsapp-rpc/node_modules/isexe/mode.js +41 -0
  277. package/server/whatsapp-rpc/node_modules/isexe/package.json +31 -0
  278. package/server/whatsapp-rpc/node_modules/isexe/test/basic.js +221 -0
  279. package/server/whatsapp-rpc/node_modules/isexe/windows.js +42 -0
  280. package/server/whatsapp-rpc/node_modules/kill-port/.editorconfig +12 -0
  281. package/server/whatsapp-rpc/node_modules/kill-port/.gitattributes +1 -0
  282. package/server/whatsapp-rpc/node_modules/kill-port/LICENSE +21 -0
  283. package/server/whatsapp-rpc/node_modules/kill-port/README.md +140 -0
  284. package/server/whatsapp-rpc/node_modules/kill-port/cli.js +25 -0
  285. package/server/whatsapp-rpc/node_modules/kill-port/example.js +21 -0
  286. package/server/whatsapp-rpc/node_modules/kill-port/index.js +46 -0
  287. package/server/whatsapp-rpc/node_modules/kill-port/logo.png +0 -0
  288. package/server/whatsapp-rpc/node_modules/kill-port/package.json +41 -0
  289. package/server/whatsapp-rpc/node_modules/kill-port/pnpm-lock.yaml +4606 -0
  290. package/server/whatsapp-rpc/node_modules/kill-port/test.js +16 -0
  291. package/server/whatsapp-rpc/node_modules/merge-stream/LICENSE +21 -0
  292. package/server/whatsapp-rpc/node_modules/merge-stream/README.md +78 -0
  293. package/server/whatsapp-rpc/node_modules/merge-stream/index.js +41 -0
  294. package/server/whatsapp-rpc/node_modules/merge-stream/package.json +19 -0
  295. package/server/whatsapp-rpc/node_modules/mimic-fn/index.d.ts +52 -0
  296. package/server/whatsapp-rpc/node_modules/mimic-fn/index.js +71 -0
  297. package/server/whatsapp-rpc/node_modules/mimic-fn/license +9 -0
  298. package/server/whatsapp-rpc/node_modules/mimic-fn/package.json +45 -0
  299. package/server/whatsapp-rpc/node_modules/mimic-fn/readme.md +90 -0
  300. package/server/whatsapp-rpc/node_modules/npm-run-path/index.d.ts +90 -0
  301. package/server/whatsapp-rpc/node_modules/npm-run-path/index.js +52 -0
  302. package/server/whatsapp-rpc/node_modules/npm-run-path/license +9 -0
  303. package/server/whatsapp-rpc/node_modules/npm-run-path/node_modules/path-key/index.d.ts +31 -0
  304. package/server/whatsapp-rpc/node_modules/npm-run-path/node_modules/path-key/index.js +12 -0
  305. package/server/whatsapp-rpc/node_modules/npm-run-path/node_modules/path-key/license +9 -0
  306. package/server/whatsapp-rpc/node_modules/npm-run-path/node_modules/path-key/package.json +41 -0
  307. package/server/whatsapp-rpc/node_modules/npm-run-path/node_modules/path-key/readme.md +57 -0
  308. package/server/whatsapp-rpc/node_modules/npm-run-path/package.json +49 -0
  309. package/server/whatsapp-rpc/node_modules/npm-run-path/readme.md +104 -0
  310. package/server/whatsapp-rpc/node_modules/onetime/index.d.ts +59 -0
  311. package/server/whatsapp-rpc/node_modules/onetime/index.js +41 -0
  312. package/server/whatsapp-rpc/node_modules/onetime/license +9 -0
  313. package/server/whatsapp-rpc/node_modules/onetime/package.json +45 -0
  314. package/server/whatsapp-rpc/node_modules/onetime/readme.md +94 -0
  315. package/server/whatsapp-rpc/node_modules/path-key/index.d.ts +40 -0
  316. package/server/whatsapp-rpc/node_modules/path-key/index.js +16 -0
  317. package/server/whatsapp-rpc/node_modules/path-key/license +9 -0
  318. package/server/whatsapp-rpc/node_modules/path-key/package.json +39 -0
  319. package/server/whatsapp-rpc/node_modules/path-key/readme.md +61 -0
  320. package/server/whatsapp-rpc/node_modules/shebang-command/index.js +19 -0
  321. package/server/whatsapp-rpc/node_modules/shebang-command/license +9 -0
  322. package/server/whatsapp-rpc/node_modules/shebang-command/package.json +34 -0
  323. package/server/whatsapp-rpc/node_modules/shebang-command/readme.md +34 -0
  324. package/server/whatsapp-rpc/node_modules/shebang-regex/index.d.ts +22 -0
  325. package/server/whatsapp-rpc/node_modules/shebang-regex/index.js +2 -0
  326. package/server/whatsapp-rpc/node_modules/shebang-regex/license +9 -0
  327. package/server/whatsapp-rpc/node_modules/shebang-regex/package.json +35 -0
  328. package/server/whatsapp-rpc/node_modules/shebang-regex/readme.md +33 -0
  329. package/server/whatsapp-rpc/node_modules/shell-exec/LICENSE +21 -0
  330. package/server/whatsapp-rpc/node_modules/shell-exec/README.md +60 -0
  331. package/server/whatsapp-rpc/node_modules/shell-exec/index.js +47 -0
  332. package/server/whatsapp-rpc/node_modules/shell-exec/package.json +29 -0
  333. package/server/whatsapp-rpc/node_modules/signal-exit/LICENSE.txt +16 -0
  334. package/server/whatsapp-rpc/node_modules/signal-exit/README.md +74 -0
  335. package/server/whatsapp-rpc/node_modules/signal-exit/dist/cjs/browser.d.ts +12 -0
  336. package/server/whatsapp-rpc/node_modules/signal-exit/dist/cjs/browser.d.ts.map +1 -0
  337. package/server/whatsapp-rpc/node_modules/signal-exit/dist/cjs/browser.js +10 -0
  338. package/server/whatsapp-rpc/node_modules/signal-exit/dist/cjs/browser.js.map +1 -0
  339. package/server/whatsapp-rpc/node_modules/signal-exit/dist/cjs/index.d.ts +48 -0
  340. package/server/whatsapp-rpc/node_modules/signal-exit/dist/cjs/index.d.ts.map +1 -0
  341. package/server/whatsapp-rpc/node_modules/signal-exit/dist/cjs/index.js +279 -0
  342. package/server/whatsapp-rpc/node_modules/signal-exit/dist/cjs/index.js.map +1 -0
  343. package/server/whatsapp-rpc/node_modules/signal-exit/dist/cjs/package.json +3 -0
  344. package/server/whatsapp-rpc/node_modules/signal-exit/dist/cjs/signals.d.ts +29 -0
  345. package/server/whatsapp-rpc/node_modules/signal-exit/dist/cjs/signals.d.ts.map +1 -0
  346. package/server/whatsapp-rpc/node_modules/signal-exit/dist/cjs/signals.js +42 -0
  347. package/server/whatsapp-rpc/node_modules/signal-exit/dist/cjs/signals.js.map +1 -0
  348. package/server/whatsapp-rpc/node_modules/signal-exit/dist/mjs/browser.d.ts +12 -0
  349. package/server/whatsapp-rpc/node_modules/signal-exit/dist/mjs/browser.d.ts.map +1 -0
  350. package/server/whatsapp-rpc/node_modules/signal-exit/dist/mjs/browser.js +4 -0
  351. package/server/whatsapp-rpc/node_modules/signal-exit/dist/mjs/browser.js.map +1 -0
  352. package/server/whatsapp-rpc/node_modules/signal-exit/dist/mjs/index.d.ts +48 -0
  353. package/server/whatsapp-rpc/node_modules/signal-exit/dist/mjs/index.d.ts.map +1 -0
  354. package/server/whatsapp-rpc/node_modules/signal-exit/dist/mjs/index.js +275 -0
  355. package/server/whatsapp-rpc/node_modules/signal-exit/dist/mjs/index.js.map +1 -0
  356. package/server/whatsapp-rpc/node_modules/signal-exit/dist/mjs/package.json +3 -0
  357. package/server/whatsapp-rpc/node_modules/signal-exit/dist/mjs/signals.d.ts +29 -0
  358. package/server/whatsapp-rpc/node_modules/signal-exit/dist/mjs/signals.d.ts.map +1 -0
  359. package/server/whatsapp-rpc/node_modules/signal-exit/dist/mjs/signals.js +39 -0
  360. package/server/whatsapp-rpc/node_modules/signal-exit/dist/mjs/signals.js.map +1 -0
  361. package/server/whatsapp-rpc/node_modules/signal-exit/package.json +106 -0
  362. package/server/whatsapp-rpc/node_modules/strip-final-newline/index.js +14 -0
  363. package/server/whatsapp-rpc/node_modules/strip-final-newline/license +9 -0
  364. package/server/whatsapp-rpc/node_modules/strip-final-newline/package.json +43 -0
  365. package/server/whatsapp-rpc/node_modules/strip-final-newline/readme.md +35 -0
  366. package/server/whatsapp-rpc/node_modules/which/CHANGELOG.md +166 -0
  367. package/server/whatsapp-rpc/node_modules/which/LICENSE +15 -0
  368. package/server/whatsapp-rpc/node_modules/which/README.md +54 -0
  369. package/server/whatsapp-rpc/node_modules/which/bin/node-which +52 -0
  370. package/server/whatsapp-rpc/node_modules/which/package.json +43 -0
  371. package/server/whatsapp-rpc/node_modules/which/which.js +125 -0
  372. package/server/whatsapp-rpc/package-lock.json +272 -0
  373. package/server/whatsapp-rpc/package.json +30 -30
  374. package/server/whatsapp-rpc/schema.json +1294 -1294
  375. package/server/whatsapp-rpc/scripts/clean.cjs +66 -66
  376. package/server/whatsapp-rpc/scripts/cli.js +162 -162
  377. package/server/whatsapp-rpc/src/go/whatsapp/history.go +166 -166
  378. package/server/whatsapp-rpc/src/python/pyproject.toml +15 -15
  379. package/server/whatsapp-rpc/src/python/whatsapp_rpc/__init__.py +4 -4
  380. package/server/whatsapp-rpc/src/python/whatsapp_rpc/client.py +427 -427
  381. package/server/whatsapp-rpc/web/app.py +609 -609
  382. package/server/whatsapp-rpc/web/requirements.txt +6 -6
  383. package/server/whatsapp-rpc/web/rpc_client.py +427 -427
  384. package/server/whatsapp-rpc/web/static/openapi.yaml +59 -59
  385. package/server/whatsapp-rpc/web/templates/base.html +149 -149
  386. package/server/whatsapp-rpc/web/templates/contacts.html +240 -240
  387. package/server/whatsapp-rpc/web/templates/dashboard.html +319 -319
  388. package/server/whatsapp-rpc/web/templates/groups.html +328 -328
  389. package/server/whatsapp-rpc/web/templates/messages.html +465 -465
  390. package/server/whatsapp-rpc/web/templates/messaging.html +680 -680
  391. package/server/whatsapp-rpc/web/templates/send.html +258 -258
  392. package/server/whatsapp-rpc/web/templates/settings.html +459 -459
  393. package/client/src/components/ui/AndroidSettingsPanel.tsx +0 -401
  394. package/client/src/components/ui/WhatsAppSettingsPanel.tsx +0 -345
  395. package/client/src/nodeDefinitions/androidDeviceNodes.ts +0 -140
  396. package/docker-compose.prod.yml +0 -107
  397. package/docker-compose.yml +0 -104
  398. package/docs-MachinaOs/README.md +0 -85
  399. package/docs-MachinaOs/deployment/docker.mdx +0 -228
  400. package/docs-MachinaOs/deployment/production.mdx +0 -345
  401. package/docs-MachinaOs/docs.json +0 -75
  402. package/docs-MachinaOs/faq.mdx +0 -309
  403. package/docs-MachinaOs/favicon.svg +0 -5
  404. package/docs-MachinaOs/installation.mdx +0 -160
  405. package/docs-MachinaOs/introduction.mdx +0 -114
  406. package/docs-MachinaOs/logo/dark.svg +0 -6
  407. package/docs-MachinaOs/logo/light.svg +0 -6
  408. package/docs-MachinaOs/nodes/ai-agent.mdx +0 -216
  409. package/docs-MachinaOs/nodes/ai-models.mdx +0 -240
  410. package/docs-MachinaOs/nodes/android.mdx +0 -411
  411. package/docs-MachinaOs/nodes/overview.mdx +0 -181
  412. package/docs-MachinaOs/nodes/schedulers.mdx +0 -316
  413. package/docs-MachinaOs/nodes/webhooks.mdx +0 -330
  414. package/docs-MachinaOs/nodes/whatsapp.mdx +0 -305
  415. package/docs-MachinaOs/quickstart.mdx +0 -119
  416. package/docs-MachinaOs/tutorials/ai-agent-workflow.mdx +0 -177
  417. package/docs-MachinaOs/tutorials/android-automation.mdx +0 -242
  418. package/docs-MachinaOs/tutorials/first-workflow.mdx +0 -134
  419. package/docs-MachinaOs/tutorials/whatsapp-automation.mdx +0 -185
  420. package/nul +0 -0
  421. package/scripts/check-ports.ps1 +0 -33
  422. package/scripts/kill-port.ps1 +0 -154
@@ -1,2415 +1,2445 @@
1
- """AI service for managing language models with LangGraph state machine support."""
2
-
3
- import re
4
- import time
5
- import httpx
6
- from dataclasses import dataclass
7
- from datetime import datetime
8
- from typing import Dict, Any, List, Optional, Callable, Type, TypedDict, Annotated, Sequence
9
- import operator
10
-
11
- from langchain_openai import ChatOpenAI
12
- from langchain_anthropic import ChatAnthropic
13
- from langchain_google_genai import ChatGoogleGenerativeAI
14
- from langchain_groq import ChatGroq
15
- from langchain_core.messages import HumanMessage, SystemMessage, AIMessage, BaseMessage, ToolMessage
16
-
17
- # Conditional import for Cerebras (requires Python <3.13)
18
- try:
19
- from langchain_cerebras import ChatCerebras
20
- CEREBRAS_AVAILABLE = True
21
- except ImportError:
22
- ChatCerebras = None
23
- CEREBRAS_AVAILABLE = False
24
- from langchain_core.tools import StructuredTool
25
- from langgraph.graph import StateGraph, END
26
- from pydantic import BaseModel, Field, create_model
27
- import json
28
-
29
- from core.config import Settings
30
- from core.logging import get_logger, log_execution_time, log_api_call
31
- from services.auth import AuthService
32
-
33
- logger = get_logger(__name__)
34
-
35
-
36
- # =============================================================================
37
- # MARKDOWN MEMORY HELPERS - Parse/append/trim conversation markdown
38
- # =============================================================================
39
-
40
- def _parse_memory_markdown(content: str) -> List[BaseMessage]:
41
- """Parse markdown memory content into LangChain messages.
42
-
43
- Markdown format:
44
- ### **Human** (timestamp)
45
- message content
46
-
47
- ### **Assistant** (timestamp)
48
- response content
49
- """
50
- messages = []
51
- pattern = r'### \*\*(Human|Assistant)\*\*[^\n]*\n(.*?)(?=\n### \*\*|$)'
52
- for role, text in re.findall(pattern, content, re.DOTALL):
53
- text = text.strip()
54
- if text:
55
- msg_class = HumanMessage if role == 'Human' else AIMessage
56
- messages.append(msg_class(content=text))
57
- return messages
58
-
59
-
60
- def _append_to_memory_markdown(content: str, role: str, message: str) -> str:
61
- """Append a message to markdown memory content."""
62
- ts = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
63
- label = "Human" if role == "human" else "Assistant"
64
- entry = f"\n### **{label}** ({ts})\n{message}\n"
65
- # Remove empty state message if present
66
- return content.replace("*No messages yet.*\n", "") + entry
67
-
68
-
69
- def _trim_markdown_window(content: str, window_size: int) -> tuple:
70
- """Keep last N message pairs, return (trimmed_content, removed_texts).
71
-
72
- Args:
73
- content: Full markdown content
74
- window_size: Number of message PAIRS to keep (human+assistant)
75
-
76
- Returns:
77
- Tuple of (trimmed markdown, list of removed message texts for archival)
78
- """
79
- pattern = r'(### \*\*(Human|Assistant)\*\*[^\n]*\n.*?)(?=\n### \*\*|$)'
80
- blocks = [m[0] for m in re.findall(pattern, content, re.DOTALL)]
81
-
82
- if len(blocks) <= window_size * 2:
83
- return content, []
84
-
85
- keep = blocks[-(window_size * 2):]
86
- removed = blocks[:-(window_size * 2)]
87
-
88
- # Extract text from removed blocks for vector storage
89
- removed_texts = []
90
- for block in removed:
91
- match = re.search(r'\n(.*)$', block, re.DOTALL)
92
- if match:
93
- removed_texts.append(match.group(1).strip())
94
-
95
- return "# Conversation History\n" + "\n".join(keep), removed_texts
96
-
97
-
98
- # Global cache for vector stores per session (InMemoryVectorStore)
99
- _memory_vector_stores: Dict[str, Any] = {}
100
-
101
-
102
- def _get_memory_vector_store(session_id: str):
103
- """Get or create InMemoryVectorStore for a session."""
104
- if session_id not in _memory_vector_stores:
105
- try:
106
- from langchain_core.vectorstores import InMemoryVectorStore
107
- from langchain_huggingface import HuggingFaceEmbeddings
108
-
109
- embeddings = HuggingFaceEmbeddings(model_name="BAAI/bge-small-en-v1.5")
110
- _memory_vector_stores[session_id] = InMemoryVectorStore(embeddings)
111
- logger.debug(f"[Memory] Created vector store for session '{session_id}'")
112
- except ImportError as e:
113
- logger.warning(f"[Memory] Vector store not available: {e}")
114
- return None
115
- return _memory_vector_stores[session_id]
116
-
117
-
118
- # =============================================================================
119
- # AI PROVIDER REGISTRY - Single source of truth for provider configurations
120
- # =============================================================================
121
-
122
- @dataclass
123
- class ProviderConfig:
124
- """Configuration for an AI provider."""
125
- name: str
126
- model_class: Type
127
- api_key_param: str # Parameter name for API key in model constructor
128
- max_tokens_param: str # Parameter name for max tokens
129
- detection_patterns: tuple # Patterns to detect this provider from model name
130
- default_model: str # Default model when none specified
131
- models_endpoint: str # API endpoint to fetch models
132
- models_header_fn: Callable[[str], dict] # Function to create headers
133
-
134
-
135
- @dataclass
136
- class ThinkingConfig:
137
- """Unified thinking/reasoning configuration across AI providers.
138
-
139
- LangChain parameters per provider (Jan 2026):
140
- - Claude: thinking={"type": "enabled", "budget_tokens": budget}, temp must be 1
141
- - OpenAI o-series: reasoning_effort ('minimal', 'low', 'medium', 'high')
142
- - Gemini 3+: thinking_level ('low', 'medium', 'high')
143
- - Gemini 2.5: thinking_budget (int tokens)
144
- - Groq: reasoning_format ('parsed', 'hidden')
145
- """
146
- enabled: bool = False
147
- budget: int = 2048 # Token budget (Claude, Gemini 2.5)
148
- effort: str = 'medium' # Effort level: 'minimal', 'low', 'medium', 'high' (OpenAI o-series)
149
- level: str = 'medium' # Thinking level: 'low', 'medium', 'high' (Gemini 3+)
150
- format: str = 'parsed' # Output format: 'parsed', 'hidden' (Groq)
151
-
152
-
153
- def _openai_headers(api_key: str) -> dict:
154
- return {'Authorization': f'Bearer {api_key}'}
155
-
156
-
157
- def _anthropic_headers(api_key: str) -> dict:
158
- return {'x-api-key': api_key, 'anthropic-version': '2023-06-01'}
159
-
160
-
161
- def _gemini_headers(api_key: str) -> dict:
162
- return {} # API key in URL for Gemini
163
-
164
-
165
- def _openrouter_headers(api_key: str) -> dict:
166
- return {
167
- 'Authorization': f'Bearer {api_key}',
168
- 'HTTP-Referer': 'http://localhost:3000',
169
- 'X-Title': 'MachinaOS'
170
- }
171
-
172
-
173
- def _groq_headers(api_key: str) -> dict:
174
- return {'Authorization': f'Bearer {api_key}'}
175
-
176
-
177
- def _cerebras_headers(api_key: str) -> dict:
178
- return {'Authorization': f'Bearer {api_key}'}
179
-
180
-
181
- # Provider configurations
182
- PROVIDER_CONFIGS: Dict[str, ProviderConfig] = {
183
- 'openai': ProviderConfig(
184
- name='openai',
185
- model_class=ChatOpenAI,
186
- api_key_param='openai_api_key',
187
- max_tokens_param='max_tokens',
188
- detection_patterns=('gpt', 'openai', 'o1'),
189
- default_model='gpt-4o-mini',
190
- models_endpoint='https://api.openai.com/v1/models',
191
- models_header_fn=_openai_headers
192
- ),
193
- 'anthropic': ProviderConfig(
194
- name='anthropic',
195
- model_class=ChatAnthropic,
196
- api_key_param='anthropic_api_key',
197
- max_tokens_param='max_tokens',
198
- detection_patterns=('claude', 'anthropic'),
199
- default_model='claude-3-5-sonnet-20241022',
200
- models_endpoint='https://api.anthropic.com/v1/models',
201
- models_header_fn=_anthropic_headers
202
- ),
203
- 'gemini': ProviderConfig(
204
- name='gemini',
205
- model_class=ChatGoogleGenerativeAI,
206
- api_key_param='google_api_key',
207
- max_tokens_param='max_output_tokens',
208
- detection_patterns=('gemini', 'google'),
209
- default_model='gemini-1.5-pro',
210
- models_endpoint='https://generativelanguage.googleapis.com/v1beta/models',
211
- models_header_fn=_gemini_headers
212
- ),
213
- 'openrouter': ProviderConfig(
214
- name='openrouter',
215
- model_class=ChatOpenAI, # OpenRouter is OpenAI API compatible
216
- api_key_param='api_key', # ChatOpenAI accepts 'api_key' as alias
217
- max_tokens_param='max_tokens',
218
- detection_patterns=('openrouter',),
219
- default_model='openai/gpt-4o-mini',
220
- models_endpoint='https://openrouter.ai/api/v1/models',
221
- models_header_fn=_openrouter_headers
222
- ),
223
- 'groq': ProviderConfig(
224
- name='groq',
225
- model_class=ChatGroq,
226
- api_key_param='api_key',
227
- max_tokens_param='max_tokens',
228
- detection_patterns=('groq',),
229
- default_model='llama-3.3-70b-versatile',
230
- models_endpoint='https://api.groq.com/openai/v1/models',
231
- models_header_fn=_groq_headers
232
- ),
233
- }
234
-
235
- # Add Cerebras only if available (requires Python <3.13)
236
- if CEREBRAS_AVAILABLE:
237
- PROVIDER_CONFIGS['cerebras'] = ProviderConfig(
238
- name='cerebras',
239
- model_class=ChatCerebras,
240
- api_key_param='api_key',
241
- max_tokens_param='max_tokens',
242
- detection_patterns=('cerebras',),
243
- default_model='llama-3.3-70b',
244
- models_endpoint='https://api.cerebras.ai/v1/models',
245
- models_header_fn=_cerebras_headers
246
- )
247
-
248
-
249
- def detect_provider_from_model(model: str) -> str:
250
- """Detect AI provider from model name using registry patterns."""
251
- model_lower = model.lower()
252
- for provider_name, config in PROVIDER_CONFIGS.items():
253
- if any(pattern in model_lower for pattern in config.detection_patterns):
254
- return provider_name
255
- return 'openai' # default
256
-
257
-
258
- def is_model_valid_for_provider(model: str, provider: str) -> bool:
259
- """Check if model name matches the provider's patterns."""
260
- config = PROVIDER_CONFIGS.get(provider)
261
- if not config:
262
- return True
263
- model_lower = model.lower()
264
- return any(pattern in model_lower for pattern in config.detection_patterns)
265
-
266
-
267
- def get_default_model(provider: str) -> str:
268
- """Get default model for a provider."""
269
- config = PROVIDER_CONFIGS.get(provider)
270
- return config.default_model if config else 'gpt-4o-mini'
271
-
272
-
273
- # =============================================================================
274
- # MESSAGE FILTERING UTILITIES - Standardized for all providers
275
- # =============================================================================
276
-
277
- def is_valid_message_content(content: Any) -> bool:
278
- """Check if message content is valid (non-empty) for API calls.
279
-
280
- This is a standardized utility for validating message content before:
281
- - Saving to conversation memory
282
- - Including in API requests
283
- - Building message history
284
-
285
- Args:
286
- content: The message content to validate (str, list, or other)
287
-
288
- Returns:
289
- True if content is valid and non-empty, False otherwise
290
- """
291
- if content is None:
292
- return False
293
-
294
- # Handle list content format (Gemini returns [{"type": "text", "text": "..."}])
295
- if isinstance(content, list):
296
- return any(
297
- (isinstance(block, dict) and block.get('text', '').strip()) or
298
- (isinstance(block, str) and block.strip())
299
- for block in content
300
- )
301
-
302
- # Handle string content (most common)
303
- if isinstance(content, str):
304
- return bool(content.strip())
305
-
306
- # Other truthy content types
307
- return bool(content)
308
-
309
-
310
- def filter_empty_messages(messages: Sequence[BaseMessage]) -> List[BaseMessage]:
311
- """Filter out messages with empty content to prevent API errors.
312
-
313
- This is a standardized utility that handles empty message filtering for all
314
- AI providers (OpenAI, Anthropic/Claude, Google Gemini, and future providers).
315
-
316
- Different providers have different sensitivities:
317
- - Gemini: Emits "HumanMessage with empty content was removed" warning
318
- - Claude/Anthropic: Throws errors for empty HumanMessage content
319
- - OpenAI: Generally tolerant but empty messages waste tokens
320
-
321
- This filter preserves:
322
- - ToolMessage: Always kept (contains tool execution results)
323
- - AIMessage with tool_calls: Kept even if content empty (tool calls are content)
324
- - SystemMessage: Kept only if has non-empty content
325
- - HumanMessage/others: Filtered if content is empty
326
-
327
- Args:
328
- messages: Sequence of LangChain BaseMessage objects
329
-
330
- Returns:
331
- Filtered list of messages with empty content removed
332
- """
333
- filtered = []
334
-
335
- for m in messages:
336
- # ToolMessage - always keep (contains tool execution results from LangGraph)
337
- if isinstance(m, ToolMessage):
338
- filtered.append(m)
339
- continue
340
-
341
- # AIMessage with tool_calls - keep even if content is empty
342
- # (the tool calls themselves are the meaningful content)
343
- if isinstance(m, AIMessage) and hasattr(m, 'tool_calls') and m.tool_calls:
344
- filtered.append(m)
345
- continue
346
-
347
- # SystemMessage - keep only if has non-empty content
348
- if isinstance(m, SystemMessage):
349
- if hasattr(m, 'content') and m.content and str(m.content).strip():
350
- filtered.append(m)
351
- continue
352
-
353
- # HumanMessage and other message types - filter out empty content
354
- if hasattr(m, 'content'):
355
- content = m.content
356
-
357
- # Handle list content format (Gemini returns [{"type": "text", "text": "..."}])
358
- if isinstance(content, list):
359
- has_content = any(
360
- (isinstance(block, dict) and block.get('text', '').strip()) or
361
- (isinstance(block, str) and block.strip())
362
- for block in content
363
- )
364
- if has_content:
365
- filtered.append(m)
366
-
367
- # Handle string content (most common)
368
- elif isinstance(content, str) and content.strip():
369
- filtered.append(m)
370
-
371
- # Handle other non-empty content types (keep if truthy)
372
- elif content:
373
- filtered.append(m)
374
- else:
375
- # Message without content attr - keep it (might be special message type)
376
- filtered.append(m)
377
-
378
- return filtered
379
-
380
-
381
- # =============================================================================
382
- # LANGGRAPH STATE MACHINE DEFINITIONS
383
- # =============================================================================
384
-
385
- class AgentState(TypedDict):
386
- """State for the LangGraph agent workflow.
387
-
388
- Uses Annotated with operator.add to accumulate messages over steps.
389
- This is the core pattern from LangGraph for stateful conversations.
390
- """
391
- messages: Annotated[Sequence[BaseMessage], operator.add]
392
- # Tool outputs storage
393
- tool_outputs: Dict[str, Any]
394
- # Tool calling support
395
- pending_tool_calls: List[Dict[str, Any]] # Tool calls from LLM to execute
396
- # Agent metadata
397
- iteration: int
398
- max_iterations: int
399
- should_continue: bool
400
- # Thinking/reasoning content accumulated across iterations
401
- thinking_content: Optional[str]
402
-
403
-
404
- def extract_thinking_from_response(response) -> tuple:
405
- """Extract text and thinking content from LLM response.
406
-
407
- Handles multiple formats:
408
- - LangChain content_blocks API (Claude, Gemini)
409
- - OpenAI responses/v1 format (content list with reasoning blocks containing summary)
410
- - Groq additional_kwargs.reasoning_content
411
- - Raw string content
412
-
413
- Returns:
414
- Tuple of (text_content: str, thinking_content: Optional[str])
415
- """
416
- text_parts = []
417
- thinking_parts = []
418
-
419
- logger.debug(f"[extract_thinking] Starting extraction, response type: {type(response).__name__}")
420
- logger.debug(f"[extract_thinking] has content_blocks: {hasattr(response, 'content_blocks')}, value: {getattr(response, 'content_blocks', None)}")
421
- logger.debug(f"[extract_thinking] has content: {hasattr(response, 'content')}, type: {type(getattr(response, 'content', None))}")
422
- logger.debug(f"[extract_thinking] has additional_kwargs: {hasattr(response, 'additional_kwargs')}, value: {getattr(response, 'additional_kwargs', None)}")
423
- logger.debug(f"[extract_thinking] has response_metadata: {hasattr(response, 'response_metadata')}, keys: {list(getattr(response, 'response_metadata', {}).keys()) if hasattr(response, 'response_metadata') else None}")
424
-
425
- # Use content_blocks API (LangChain 1.0+) for Claude/Gemini
426
- if hasattr(response, 'content_blocks') and response.content_blocks:
427
- for block in response.content_blocks:
428
- if isinstance(block, dict):
429
- block_type = block.get("type", "")
430
- if block_type == "reasoning":
431
- thinking_parts.append(block.get("reasoning", ""))
432
- elif block_type == "thinking":
433
- thinking_parts.append(block.get("thinking", ""))
434
- elif block_type == "text":
435
- text_parts.append(block.get("text", ""))
436
-
437
- # Check additional_kwargs for reasoning_content (Groq, older OpenAI responses)
438
- if not thinking_parts and hasattr(response, 'additional_kwargs'):
439
- reasoning = response.additional_kwargs.get('reasoning_content')
440
- if reasoning:
441
- thinking_parts.append(reasoning)
442
-
443
- # Check response_metadata for OpenAI o-series reasoning (responses/v1 format)
444
- # The output array contains reasoning items with summaries
445
- if not thinking_parts and hasattr(response, 'response_metadata'):
446
- metadata = response.response_metadata
447
- output = metadata.get('output', [])
448
- if isinstance(output, list):
449
- for item in output:
450
- if isinstance(item, dict) and item.get('type') == 'reasoning':
451
- summary = item.get('summary', [])
452
- if isinstance(summary, list):
453
- for s in summary:
454
- if isinstance(s, dict):
455
- # Handle both summary_text and text types
456
- text = s.get('text', '')
457
- if text:
458
- thinking_parts.append(text)
459
- elif isinstance(s, str):
460
- thinking_parts.append(s)
461
-
462
- # Check raw content for OpenAI responses/v1 format and other list formats
463
- if hasattr(response, 'content'):
464
- content = response.content
465
- if isinstance(content, str):
466
- if not text_parts:
467
- text_parts.append(content)
468
- elif isinstance(content, list):
469
- for block in content:
470
- if isinstance(block, dict):
471
- block_type = block.get('type', '')
472
- if block_type == 'text' or block_type == 'output_text':
473
- # Handle both 'text' and 'output_text' (responses/v1 format)
474
- if not text_parts: # Only add if not already extracted
475
- text_parts.append(block.get('text', ''))
476
- elif block_type == 'reasoning':
477
- # OpenAI responses/v1 format: reasoning block with summary array
478
- # Format: {"type": "reasoning", "summary": [{"type": "text", "text": "..."}, {"type": "summary_text", "text": "..."}]}
479
- summary = block.get('summary', [])
480
- if isinstance(summary, list):
481
- for s in summary:
482
- if isinstance(s, dict):
483
- s_type = s.get('type', '')
484
- if s_type in ('text', 'summary_text'):
485
- thinking_parts.append(s.get('text', ''))
486
- elif isinstance(s, str):
487
- thinking_parts.append(s)
488
- elif isinstance(summary, str):
489
- thinking_parts.append(summary)
490
- # Also check direct reasoning field
491
- if block.get('reasoning'):
492
- thinking_parts.append(block.get('reasoning', ''))
493
- elif block_type == 'thinking':
494
- thinking_parts.append(block.get('thinking', ''))
495
- elif isinstance(block, str) and not text_parts:
496
- text_parts.append(block)
497
-
498
- text = '\n'.join(filter(None, text_parts))
499
- thinking = '\n'.join(filter(None, thinking_parts)) if thinking_parts else None
500
-
501
- logger.debug(f"[extract_thinking] Final text_parts: {text_parts}")
502
- logger.debug(f"[extract_thinking] Final thinking_parts: {thinking_parts}")
503
- logger.debug(f"[extract_thinking] Returning text={repr(text[:100] if text else None)}, thinking={repr(thinking[:100] if thinking else None)}")
504
-
505
- return text, thinking
506
-
507
-
508
- def create_agent_node(chat_model):
509
- """Create the agent node function for LangGraph.
510
-
511
- The agent node:
512
- 1. Receives current state with messages
513
- 2. Invokes the LLM
514
- 3. Extracts thinking content if present
515
- 4. Checks for tool calls in response
516
- 5. Returns updated state with new AI message, thinking, and pending tool calls
517
- """
518
- def agent_node(state: AgentState) -> Dict[str, Any]:
519
- """Process messages through the LLM and return response."""
520
- messages = state["messages"]
521
- iteration = state.get("iteration", 0)
522
- max_iterations = state.get("max_iterations", 10)
523
- existing_thinking = state.get("thinking_content") or ""
524
-
525
- logger.debug(f"[LangGraph] Agent node invoked, iteration={iteration}, messages={len(messages)}")
526
-
527
- # Filter out messages with empty content using standardized utility
528
- # Prevents API errors/warnings from Gemini, Claude, and other providers
529
- filtered_messages = filter_empty_messages(messages)
530
-
531
- if len(filtered_messages) != len(messages):
532
- logger.debug(f"[LangGraph] Filtered out {len(messages) - len(filtered_messages)} empty messages")
533
-
534
- # Invoke the model
535
- response = chat_model.invoke(filtered_messages)
536
-
537
- logger.debug(f"[LangGraph] LLM response type: {type(response).__name__}")
538
-
539
- # Extract thinking content from response
540
- _, new_thinking = extract_thinking_from_response(response)
541
-
542
- # Accumulate thinking across iterations (for multi-step tool usage)
543
- accumulated_thinking = existing_thinking
544
- if new_thinking:
545
- if accumulated_thinking:
546
- accumulated_thinking = f"{accumulated_thinking}\n\n--- Iteration {iteration + 1} ---\n{new_thinking}"
547
- else:
548
- accumulated_thinking = new_thinking
549
- logger.debug(f"[LangGraph] Extracted thinking content ({len(new_thinking)} chars)")
550
-
551
- # Check for Gemini-specific response attributes (safety ratings, block reason)
552
- if hasattr(response, 'response_metadata'):
553
- meta = response.response_metadata
554
- if meta.get('finish_reason') == 'SAFETY':
555
- logger.warning("[LangGraph] Gemini response blocked by safety filters")
556
- if meta.get('block_reason'):
557
- logger.warning(f"[LangGraph] Gemini block reason: {meta.get('block_reason')}")
558
-
559
- # Check for tool calls in the response
560
- pending_tool_calls = []
561
- should_continue = False
562
-
563
- if hasattr(response, 'tool_calls') and response.tool_calls:
564
- # Model wants to use tools
565
- pending_tool_calls = response.tool_calls
566
- should_continue = True
567
- logger.debug(f"[LangGraph] Agent requesting {len(pending_tool_calls)} tool call(s)")
568
-
569
- return {
570
- "messages": [response], # Will be appended via operator.add
571
- "tool_outputs": {},
572
- "pending_tool_calls": pending_tool_calls,
573
- "iteration": iteration + 1,
574
- "max_iterations": max_iterations,
575
- "should_continue": should_continue,
576
- "thinking_content": accumulated_thinking or None
577
- }
578
-
579
- return agent_node
580
-
581
-
582
- def create_tool_node(tool_executor: Callable):
583
- """Create an async tool execution node for LangGraph.
584
-
585
- The tool node:
586
- 1. Receives pending tool calls from agent
587
- 2. Executes each tool via the async tool_executor callback
588
- 3. Returns ToolMessages with results for the agent
589
-
590
- Note: This returns an async function for use with ainvoke().
591
- LangGraph supports async node functions natively.
592
- """
593
- async def tool_node(state: AgentState) -> Dict[str, Any]:
594
- """Execute pending tool calls and return results as ToolMessages."""
595
- tool_messages = []
596
-
597
- for tool_call in state.get("pending_tool_calls", []):
598
- tool_name = tool_call.get("name", "unknown")
599
- tool_args = tool_call.get("args", {})
600
- tool_id = tool_call.get("id", "")
601
-
602
- logger.info(f"[LangGraph] Executing tool: {tool_name} (args={tool_args})")
603
-
604
- try:
605
- # Directly await the async tool executor (proper async pattern)
606
- result = await tool_executor(tool_name, tool_args)
607
- logger.info(f"[LangGraph] Tool {tool_name} returned: {str(result)[:100]}")
608
- except Exception as e:
609
- logger.error(f"[LangGraph] Tool execution failed: {tool_name}", error=str(e))
610
- result = {"error": str(e)}
611
-
612
- # Create ToolMessage with result
613
- tool_messages.append(ToolMessage(
614
- content=json.dumps(result, default=str),
615
- tool_call_id=tool_id,
616
- name=tool_name
617
- ))
618
-
619
- logger.info(f"[LangGraph] Tool {tool_name} completed, result added to messages")
620
-
621
- return {
622
- "messages": tool_messages,
623
- "pending_tool_calls": [], # Clear pending after execution
624
- }
625
-
626
- return tool_node
627
-
628
-
629
- def should_continue(state: AgentState) -> str:
630
- """Determine if the agent should continue or end.
631
-
632
- This is the conditional edge function for LangGraph.
633
- Returns "tools" to execute pending tool calls, or "end" to finish.
634
- """
635
- if state.get("should_continue", False):
636
- if state.get("iteration", 0) < state.get("max_iterations", 10):
637
- return "tools"
638
- return "end"
639
-
640
-
641
- def build_agent_graph(chat_model, tools: List = None, tool_executor: Callable = None):
642
- """Build the LangGraph agent workflow with optional tool support.
643
-
644
- Architecture (with tools):
645
- START -> agent -> (conditional) -> tools -> agent -> ... -> END
646
- |
647
- +-> END (no tool calls)
648
-
649
- Architecture (without tools):
650
- START -> agent -> END
651
-
652
- Args:
653
- chat_model: The LangChain chat model
654
- tools: Optional list of LangChain tools to bind to the model
655
- tool_executor: Optional async callback to execute tools
656
- """
657
- # Create the graph with our state schema
658
- graph = StateGraph(AgentState)
659
-
660
- # Bind tools to model if provided
661
- model_with_tools = chat_model
662
- if tools:
663
- model_with_tools = chat_model.bind_tools(tools)
664
- logger.debug(f"[LangGraph] Bound {len(tools)} tools to model")
665
-
666
- # Add the agent node
667
- agent_fn = create_agent_node(model_with_tools)
668
- graph.add_node("agent", agent_fn)
669
-
670
- # Set entry point
671
- graph.set_entry_point("agent")
672
-
673
- if tools and tool_executor:
674
- # Add tool execution node
675
- tool_fn = create_tool_node(tool_executor)
676
- graph.add_node("tools", tool_fn)
677
-
678
- # Conditional routing: agent -> tools or end
679
- graph.add_conditional_edges(
680
- "agent",
681
- should_continue,
682
- {
683
- "tools": "tools",
684
- "end": END
685
- }
686
- )
687
-
688
- # Tools always route back to agent
689
- graph.add_edge("tools", "agent")
690
-
691
- logger.debug("[LangGraph] Built graph with tool execution loop")
692
- else:
693
- # Simple graph without tools
694
- graph.add_conditional_edges(
695
- "agent",
696
- should_continue,
697
- {
698
- "tools": "agent", # Fallback loop (shouldn't happen without tools)
699
- "end": END
700
- }
701
- )
702
-
703
- # Compile the graph
704
- return graph.compile()
705
-
706
-
707
- class AIService:
708
- """AI model service for LangChain operations."""
709
-
710
- def __init__(self, auth_service: AuthService, database, cache, settings: Settings):
711
- self.auth = auth_service
712
- self.database = database
713
- self.cache = cache
714
- self.settings = settings
715
-
716
- def detect_provider(self, model: str) -> str:
717
- """Detect AI provider from model name."""
718
- return detect_provider_from_model(model)
719
-
720
- def _extract_text_content(self, content, ai_response=None) -> str:
721
- """Extract text content from various response formats.
722
-
723
- Handles:
724
- - String content (OpenAI, Anthropic)
725
- - List of content blocks (Gemini 3+ models)
726
- - Empty/None content with error details from metadata
727
-
728
- Args:
729
- content: The raw content from response (str, list, or None)
730
- ai_response: The full AIMessage for metadata inspection
731
-
732
- Returns:
733
- Extracted text string
734
-
735
- Raises:
736
- ValueError: If content is empty with details about why
737
- """
738
- # Handle list content (Gemini format: [{"type": "text", "text": "..."}])
739
- if isinstance(content, list):
740
- text_parts = []
741
- for block in content:
742
- if isinstance(block, dict):
743
- if block.get('type') == 'text' and block.get('text'):
744
- text_parts.append(block['text'])
745
- elif 'text' in block:
746
- text_parts.append(str(block['text']))
747
- elif isinstance(block, str):
748
- text_parts.append(block)
749
- extracted = '\n'.join(text_parts)
750
- if extracted.strip():
751
- return extracted
752
- # List was present but no text extracted
753
- logger.warning(f"[LangGraph] Content was list but no text extracted: {content}")
754
-
755
- # Handle string content
756
- if isinstance(content, str) and content.strip():
757
- return content
758
-
759
- # Content is empty - try to get error details from metadata
760
- error_details = []
761
- if ai_response and hasattr(ai_response, 'response_metadata'):
762
- meta = ai_response.response_metadata
763
- finish_reason = meta.get('finish_reason', '')
764
-
765
- if finish_reason == 'SAFETY':
766
- error_details.append("Content blocked by safety filters")
767
- # Try to get specific blocked categories
768
- safety_ratings = meta.get('safety_ratings', [])
769
- blocked = [r.get('category') for r in safety_ratings if r.get('blocked')]
770
- if blocked:
771
- error_details.append(f"Blocked categories: {', '.join(blocked)}")
772
-
773
- elif finish_reason == 'MAX_TOKENS':
774
- # Check if reasoning consumed all tokens
775
- token_details = meta.get('output_token_details', {})
776
- reasoning_tokens = token_details.get('reasoning', 0)
777
- output_tokens = meta.get('usage_metadata', {}).get('candidates_token_count', 0)
778
- if reasoning_tokens > 0 and output_tokens == 0:
779
- error_details.append(f"Model used all tokens for reasoning ({reasoning_tokens} tokens). Try increasing max_tokens or simplifying the prompt.")
780
- else:
781
- error_details.append("Response truncated due to max_tokens limit")
782
-
783
- elif finish_reason == 'MALFORMED_FUNCTION_CALL':
784
- error_details.append("Model returned malformed function call. Tool schema may be incompatible.")
785
-
786
- if meta.get('block_reason'):
787
- error_details.append(f"Block reason: {meta.get('block_reason')}")
788
-
789
- if error_details:
790
- raise ValueError(f"AI returned empty response. {'; '.join(error_details)}")
791
-
792
- # Generic empty response
793
- logger.warning(f"[LangGraph] Empty response with no error details. Content type: {type(content)}, value: {content}")
794
- raise ValueError("AI generated empty response. Try rephrasing your prompt or using a different model.")
795
-
796
- def _is_reasoning_model(self, model: str) -> bool:
797
- """Check if model supports reasoning (OpenAI o-series).
798
-
799
- O-series models: o1, o1-mini, o1-preview, o3, o3-mini, o4-mini, etc.
800
- NOT gpt-4o (which contains 'o' but is not a reasoning model).
801
- """
802
- model_lower = model.lower()
803
- # Check for o-series pattern: starts with 'o' followed by digit
804
- # e.g., o1, o1-mini, o3, o3-mini, o4-mini
805
- return bool(re.match(r'^o[134](-|$)', model_lower))
806
-
807
- def create_model(self, provider: str, api_key: str, model: str,
808
- temperature: float, max_tokens: int,
809
- thinking: Optional[ThinkingConfig] = None):
810
- """Create LangChain model instance using provider registry.
811
-
812
- Args:
813
- provider: AI provider name (openai, anthropic, gemini, groq, openrouter)
814
- api_key: Provider API key
815
- model: Model name/ID
816
- temperature: Sampling temperature
817
- max_tokens: Maximum response tokens
818
- thinking: Optional thinking/reasoning configuration
819
-
820
- Returns:
821
- Configured LangChain chat model instance
822
- """
823
- config = PROVIDER_CONFIGS.get(provider)
824
- if not config:
825
- raise ValueError(f"Unsupported provider: {provider}")
826
-
827
- # Build kwargs dynamically from registry config
828
- kwargs = {
829
- config.api_key_param: api_key,
830
- 'model': model,
831
- 'temperature': temperature,
832
- config.max_tokens_param: max_tokens
833
- }
834
-
835
- # OpenRouter uses OpenAI-compatible API with custom base_url
836
- if provider == 'openrouter':
837
- kwargs['base_url'] = 'https://openrouter.ai/api/v1'
838
- kwargs['default_headers'] = {
839
- 'HTTP-Referer': 'http://localhost:3000',
840
- 'X-Title': 'MachinaOS'
841
- }
842
-
843
- # OpenAI o-series reasoning models ALWAYS require temperature=1
844
- # This applies regardless of whether thinking mode is enabled
845
- if provider == 'openai' and self._is_reasoning_model(model):
846
- if kwargs.get('temperature', 1) != 1:
847
- logger.info(f"[AI] OpenAI o-series model '{model}': forcing temperature to 1 (was {kwargs.get('temperature')})")
848
- kwargs['temperature'] = 1
849
-
850
- # Apply thinking/reasoning configuration per provider (per LangChain docs Jan 2026)
851
- if thinking and thinking.enabled:
852
- if provider == 'anthropic':
853
- # Claude extended thinking: thinking={"type": "enabled", "budget_tokens": N}
854
- # Requires temperature=1, budget min 1024 tokens
855
- # IMPORTANT: max_tokens must be greater than budget_tokens
856
- budget = max(1024, thinking.budget)
857
- # Ensure max_tokens > budget_tokens (add buffer for response)
858
- if max_tokens <= budget:
859
- # Set max_tokens to budget + reasonable response space (at least 1024 more)
860
- kwargs[config.max_tokens_param] = budget + max(1024, max_tokens)
861
- logger.info(f"[AI] Claude thinking: adjusted max_tokens from {max_tokens} to {kwargs[config.max_tokens_param]} (budget={budget})")
862
- kwargs['thinking'] = {"type": "enabled", "budget_tokens": budget}
863
- kwargs['temperature'] = 1 # Required for Claude thinking mode
864
- elif provider == 'openai' and self._is_reasoning_model(model):
865
- # OpenAI o-series: Use reasoning_effort parameter
866
- # Note: reasoning.summary requires organization verification on OpenAI
867
- # So we just use reasoning_effort for now
868
- kwargs['reasoning_effort'] = thinking.effort
869
- # O-series models only support temperature=1
870
- kwargs['temperature'] = 1
871
- logger.info(f"[AI] OpenAI o-series: reasoning_effort={thinking.effort}, temperature=1")
872
- elif provider == 'gemini':
873
- # Gemini thinking support varies by model:
874
- # - Gemini 2.5 Flash/Pro: Use thinking_budget (int tokens, 0=off, -1=dynamic)
875
- # - Gemini 2.0 Flash Thinking: Built-in thinking, use thinking_budget
876
- # - Gemini 3 models: Limited/experimental thinking support
877
- model_lower = model.lower()
878
-
879
- # Gemini 2.5 models support thinking_budget
880
- if 'gemini-2.5' in model_lower or '2.5' in model_lower:
881
- kwargs['thinking_budget'] = thinking.budget
882
- kwargs['include_thoughts'] = True
883
- logger.info(f"[AI] Gemini 2.5 thinking: budget={thinking.budget}")
884
- # Gemini 2.0 Flash Thinking model
885
- elif 'gemini-2.0-flash-thinking' in model_lower or 'flash-thinking' in model_lower:
886
- kwargs['thinking_budget'] = thinking.budget
887
- kwargs['include_thoughts'] = True
888
- logger.info(f"[AI] Gemini Flash Thinking: budget={thinking.budget}")
889
- # Gemini 3 preview models - thinking support is experimental/limited
890
- # Only 'low' and 'high' levels may be supported, not 'medium'
891
- elif 'gemini-3' in model_lower:
892
- # For Gemini 3, try thinking_budget instead of thinking_level
893
- # as level support is inconsistent across preview models
894
- kwargs['thinking_budget'] = thinking.budget
895
- kwargs['include_thoughts'] = True
896
- logger.info(f"[AI] Gemini 3 thinking: using budget={thinking.budget} (level support varies)")
897
- # Other Gemini 2.x models - try thinking_budget
898
- elif 'gemini-2' in model_lower:
899
- kwargs['thinking_budget'] = thinking.budget
900
- kwargs['include_thoughts'] = True
901
- logger.info(f"[AI] Gemini 2.x thinking: budget={thinking.budget}")
902
- else:
903
- # For other/older Gemini models, thinking may not be supported
904
- logger.warning(f"[AI] Gemini model '{model}' may not support thinking mode")
905
- elif provider == 'groq':
906
- # Groq: reasoning_format ('parsed' or 'hidden')
907
- # 'parsed' includes reasoning in additional_kwargs, 'hidden' suppresses it
908
- format_val = thinking.format if thinking.format in ('parsed', 'hidden') else 'parsed'
909
- kwargs['reasoning_format'] = format_val
910
- elif provider == 'cerebras':
911
- # Cerebras: No official LangChain thinking support yet
912
- # Passing through as model_kwargs if supported
913
- kwargs['thinking_budget'] = thinking.budget
914
-
915
- return config.model_class(**kwargs)
916
-
917
- async def fetch_models(self, provider: str, api_key: str) -> List[str]:
918
- """Fetch available models from provider API."""
919
- async with httpx.AsyncClient(timeout=self.settings.ai_timeout) as client:
920
- if provider == 'openai':
921
- response = await client.get(
922
- 'https://api.openai.com/v1/models',
923
- headers={'Authorization': f'Bearer {api_key}'}
924
- )
925
- response.raise_for_status()
926
- data = response.json()
927
-
928
- # Filter for chat models including o-series reasoning models
929
- models = []
930
- for model in data.get('data', []):
931
- model_id = model['id'].lower()
932
- # Include GPT models and o-series reasoning models (o1, o3, o4)
933
- is_gpt = 'gpt' in model_id
934
- is_o_series = any(f'o{n}' in model_id for n in ['1', '3', '4'])
935
- is_excluded = 'instruct' in model_id or 'embedding' in model_id or 'realtime' in model_id
936
- if (is_gpt or is_o_series) and not is_excluded:
937
- models.append(model['id'])
938
-
939
- # Sort by priority - o-series reasoning models at top
940
- def get_priority(model_name: str) -> int:
941
- m = model_name.lower()
942
- # O-series reasoning models first
943
- if 'o4-mini' in m: return 1
944
- if 'o4' in m: return 2
945
- if 'o3-mini' in m: return 3
946
- if 'o3' in m: return 4
947
- if 'o1-mini' in m: return 5
948
- if 'o1' in m: return 6
949
- # Then GPT models
950
- if 'gpt-4o-mini' in m: return 10
951
- if 'gpt-4o' in m: return 11
952
- if 'gpt-4-turbo' in m: return 12
953
- if 'gpt-4' in m: return 13
954
- if 'gpt-3.5' in m: return 20
955
- return 99
956
-
957
- return sorted(models, key=get_priority)
958
-
959
- elif provider == 'anthropic':
960
- response = await client.get(
961
- 'https://api.anthropic.com/v1/models',
962
- headers={
963
- 'x-api-key': api_key,
964
- 'anthropic-version': '2023-06-01'
965
- }
966
- )
967
- response.raise_for_status()
968
- data = response.json()
969
- return [model['id'] for model in data.get('data', [])
970
- if model.get('type') == 'model']
971
-
972
- elif provider == 'gemini':
973
- response = await client.get(
974
- f'https://generativelanguage.googleapis.com/v1beta/models?key={api_key}'
975
- )
976
- response.raise_for_status()
977
- data = response.json()
978
-
979
- models = []
980
- for model in data.get('models', []):
981
- name = model.get('name', '')
982
- if ('gemini' in name and
983
- 'generateContent' in model.get('supportedGenerationMethods', [])):
984
- models.append(name.replace('models/', ''))
985
-
986
- return sorted(models)
987
-
988
- elif provider == 'openrouter':
989
- response = await client.get(
990
- 'https://openrouter.ai/api/v1/models',
991
- headers={'Authorization': f'Bearer {api_key}'}
992
- )
993
- response.raise_for_status()
994
- data = response.json()
995
-
996
- free_models = []
997
- paid_models = []
998
- for model in data.get('data', []):
999
- model_id = model.get('id', '')
1000
- arch = model.get('architecture', {})
1001
- modality = arch.get('modality', '')
1002
- if 'text' in modality and model_id:
1003
- pricing = model.get('pricing', {})
1004
- prompt_price = float(pricing.get('prompt', '0') or '0')
1005
- completion_price = float(pricing.get('completion', '0') or '0')
1006
- is_free = prompt_price == 0 and completion_price == 0
1007
- # Add [FREE] tag to free models
1008
- display_name = f"[FREE] {model_id}" if is_free else model_id
1009
- if is_free:
1010
- free_models.append(display_name)
1011
- else:
1012
- paid_models.append(display_name)
1013
-
1014
- # Return free models first, then paid models (both sorted)
1015
- return sorted(free_models) + sorted(paid_models)
1016
-
1017
- elif provider == 'groq':
1018
- response = await client.get(
1019
- 'https://api.groq.com/openai/v1/models',
1020
- headers={'Authorization': f'Bearer {api_key}'}
1021
- )
1022
- response.raise_for_status()
1023
- data = response.json()
1024
-
1025
- models = []
1026
- for model in data.get('data', []):
1027
- model_id = model.get('id', '')
1028
- # Include all models from Groq API
1029
- if model_id:
1030
- models.append(model_id)
1031
-
1032
- return sorted(models)
1033
-
1034
- elif provider == 'cerebras':
1035
- response = await client.get(
1036
- 'https://api.cerebras.ai/v1/models',
1037
- headers={'Authorization': f'Bearer {api_key}'}
1038
- )
1039
- response.raise_for_status()
1040
- data = response.json()
1041
-
1042
- models = []
1043
- for model in data.get('data', []):
1044
- model_id = model.get('id', '')
1045
- # Include all models from Cerebras API
1046
- if model_id:
1047
- models.append(model_id)
1048
-
1049
- return sorted(models)
1050
-
1051
- else:
1052
- raise ValueError(f"Unsupported provider: {provider}")
1053
-
1054
- async def execute_chat(self, node_id: str, node_type: str, parameters: Dict[str, Any]) -> Dict[str, Any]:
1055
- """Execute AI chat model."""
1056
- start_time = time.time()
1057
-
1058
- try:
1059
- # Flatten options collection from frontend
1060
- options = parameters.get('options', {})
1061
- flattened = {**parameters, **options}
1062
-
1063
- # Extract parameters with camelCase/snake_case support for LangChain
1064
- api_key = flattened.get('api_key') or flattened.get('apiKey')
1065
- model = flattened.get('model', 'gpt-3.5-turbo')
1066
- # Strip [FREE] prefix if present (added by OpenRouter model list for display)
1067
- if model.startswith('[FREE] '):
1068
- model = model[7:]
1069
- prompt = flattened.get('prompt', 'Hello')
1070
-
1071
- # System prompt/message - support multiple naming conventions
1072
- system_prompt = (flattened.get('system_prompt') or
1073
- flattened.get('systemMessage') or
1074
- flattened.get('systemPrompt') or '')
1075
-
1076
- # Max tokens - support camelCase from frontend
1077
- max_tokens = int(flattened.get('max_tokens') or
1078
- flattened.get('maxTokens') or 1000)
1079
-
1080
- temperature = float(flattened.get('temperature', 0.7))
1081
-
1082
- if not api_key:
1083
- raise ValueError("API key is required")
1084
-
1085
- # Validate prompt is not empty (prevents wasted API calls for all providers)
1086
- if not is_valid_message_content(prompt):
1087
- raise ValueError("Prompt cannot be empty")
1088
-
1089
- # Determine provider from node_type (more reliable than model name detection)
1090
- # OpenRouter models have format like "openai/gpt-4o" which would incorrectly detect as openai
1091
- if node_type == 'openrouterChatModel':
1092
- provider = 'openrouter'
1093
- elif node_type == 'groqChatModel':
1094
- provider = 'groq'
1095
- elif node_type == 'cerebrasChatModel':
1096
- provider = 'cerebras'
1097
- else:
1098
- provider = self.detect_provider(model)
1099
-
1100
- # Build thinking config from parameters
1101
- thinking_config = None
1102
- if flattened.get('thinkingEnabled'):
1103
- thinking_config = ThinkingConfig(
1104
- enabled=True,
1105
- budget=int(flattened.get('thinkingBudget', 2048)),
1106
- effort=flattened.get('reasoningEffort', 'medium'),
1107
- level=flattened.get('thinkingLevel', 'medium'),
1108
- format=flattened.get('reasoningFormat', 'parsed'),
1109
- )
1110
-
1111
- chat_model = self.create_model(provider, api_key, model, temperature, max_tokens, thinking_config)
1112
-
1113
- # Prepare messages
1114
- messages = []
1115
- if system_prompt and is_valid_message_content(system_prompt):
1116
- messages.append(SystemMessage(content=system_prompt))
1117
- messages.append(HumanMessage(content=prompt))
1118
-
1119
- # Filter messages using standardized utility (handles all providers consistently)
1120
- filtered_messages = filter_empty_messages(messages)
1121
-
1122
- # Execute
1123
- response = chat_model.invoke(filtered_messages)
1124
-
1125
- # Debug: Log response structure for o-series reasoning models
1126
- if thinking_config and thinking_config.enabled:
1127
- logger.info(f"[AI Debug] Response type: {type(response).__name__}")
1128
- logger.info(f"[AI Debug] Response content type: {type(response.content)}")
1129
- logger.info(f"[AI Debug] Response content: {response.content[:500] if isinstance(response.content, str) else response.content}")
1130
- if hasattr(response, 'content_blocks'):
1131
- logger.info(f"[AI Debug] content_blocks: {response.content_blocks}")
1132
- if hasattr(response, 'additional_kwargs'):
1133
- logger.info(f"[AI Debug] additional_kwargs: {response.additional_kwargs}")
1134
- if hasattr(response, 'response_metadata'):
1135
- logger.info(f"[AI Debug] response_metadata: {response.response_metadata}")
1136
-
1137
- # Extract text and thinking content from response
1138
- text_content, thinking_content = extract_thinking_from_response(response)
1139
-
1140
- # Debug: Log extraction results
1141
- logger.info(f"[AI Debug] Extracted text_content: {repr(text_content[:200] if text_content else None)}")
1142
- logger.info(f"[AI Debug] Extracted thinking_content: {repr(thinking_content[:200] if thinking_content else None)}")
1143
-
1144
- # Use extracted text if available, fall back to raw content
1145
- response_text = text_content if text_content else response.content
1146
-
1147
- logger.info(f"[AI Debug] Final response_text: {repr(response_text[:200] if response_text else None)}")
1148
-
1149
- result = {
1150
- "response": response_text,
1151
- "thinking": thinking_content,
1152
- "thinking_enabled": thinking_config.enabled if thinking_config else False,
1153
- "model": model,
1154
- "provider": provider,
1155
- "finish_reason": "stop",
1156
- "timestamp": datetime.now().isoformat(),
1157
- "input": {
1158
- "prompt": prompt,
1159
- "system_prompt": system_prompt,
1160
- }
1161
- }
1162
-
1163
- log_execution_time(logger, "ai_chat", start_time, time.time())
1164
- log_api_call(logger, provider, model, "chat", True)
1165
-
1166
- final_result = {
1167
- "success": True,
1168
- "node_id": node_id,
1169
- "node_type": node_type,
1170
- "result": result,
1171
- "execution_time": time.time() - start_time
1172
- }
1173
- logger.info(f"[AI Debug] Returning final_result: success={final_result['success']}, result.response={repr(result.get('response', 'MISSING')[:100] if result.get('response') else 'None')}")
1174
- return final_result
1175
-
1176
- except Exception as e:
1177
- logger.error("AI execution failed", node_id=node_id, error=str(e))
1178
- log_api_call(logger, provider if 'provider' in locals() else 'unknown',
1179
- model if 'model' in locals() else 'unknown', "chat", False, error=str(e))
1180
-
1181
- return {
1182
- "success": False,
1183
- "node_id": node_id,
1184
- "node_type": node_type,
1185
- "error": str(e),
1186
- "execution_time": time.time() - start_time,
1187
- "timestamp": datetime.now().isoformat()
1188
- }
1189
-
1190
- async def execute_agent(self, node_id: str, parameters: Dict[str, Any],
1191
- memory_data: Optional[Dict[str, Any]] = None,
1192
- tool_data: Optional[List[Dict[str, Any]]] = None,
1193
- broadcaster = None,
1194
- workflow_id: Optional[str] = None) -> Dict[str, Any]:
1195
- """Execute AI Agent using LangGraph state machine.
1196
-
1197
- This method uses LangGraph for structured agent execution with:
1198
- - State management via TypedDict
1199
- - Tool calling via bind_tools and tool execution node
1200
- - Message accumulation via operator.add pattern
1201
- - Real-time status broadcasts for UI animations
1202
-
1203
- Args:
1204
- node_id: The node identifier
1205
- parameters: Node parameters including prompt, model, etc.
1206
- memory_data: Optional memory data from connected simpleMemory node
1207
- containing session_id, window_size for conversation history
1208
- tool_data: Optional list of tool configurations from connected tool nodes
1209
- broadcaster: Optional StatusBroadcaster for real-time UI updates
1210
- workflow_id: Optional workflow ID for scoped status broadcasts
1211
- """
1212
- start_time = time.time()
1213
- provider = 'unknown'
1214
- model = 'unknown'
1215
-
1216
- # EARLY LOG: Entry point for debugging
1217
- logger.info(f"[AIAgent] execute_agent called: node_id={node_id}, workflow_id={workflow_id}, tool_data_count={len(tool_data) if tool_data else 0}")
1218
- if tool_data:
1219
- for i, td in enumerate(tool_data):
1220
- logger.info(f"[AIAgent] Tool {i}: type={td.get('node_type')}, node_id={td.get('node_id')}")
1221
-
1222
- # Helper to broadcast status updates with workflow_id for proper scoping
1223
- async def broadcast_status(phase: str, details: Dict[str, Any] = None):
1224
- if broadcaster:
1225
- await broadcaster.update_node_status(node_id, "executing", {
1226
- "phase": phase,
1227
- "agent_type": "langgraph",
1228
- **(details or {})
1229
- }, workflow_id=workflow_id)
1230
-
1231
- try:
1232
- # Extract top-level parameters (always visible in UI)
1233
- prompt = parameters.get('prompt', 'Hello')
1234
- system_message = parameters.get('systemMessage', 'You are a helpful assistant')
1235
-
1236
- # Flatten options collection from frontend
1237
- options = parameters.get('options', {})
1238
- flattened = {**parameters, **options}
1239
-
1240
- # Extract parameters with camelCase/snake_case support
1241
- api_key = flattened.get('api_key') or flattened.get('apiKey')
1242
- provider = parameters.get('provider', 'openai')
1243
- model = parameters.get('model', '')
1244
- temperature = float(flattened.get('temperature', 0.7))
1245
- max_tokens = int(flattened.get('max_tokens') or flattened.get('maxTokens') or 1000)
1246
-
1247
- logger.info(f"[LangGraph] Agent: {provider}/{model}, tools={len(tool_data) if tool_data else 0}")
1248
-
1249
- # If no model specified or model doesn't match provider, use default from registry
1250
- if not model or not is_model_valid_for_provider(model, provider):
1251
- old_model = model
1252
- model = get_default_model(provider)
1253
- if old_model:
1254
- logger.warning(f"Model '{old_model}' invalid for provider '{provider}', using default: {model}")
1255
- else:
1256
- logger.info(f"No model specified, using default: {model}")
1257
-
1258
- if not api_key:
1259
- raise ValueError("API key is required for AI Agent")
1260
-
1261
- # Build thinking config from parameters
1262
- thinking_config = None
1263
- if flattened.get('thinkingEnabled'):
1264
- thinking_config = ThinkingConfig(
1265
- enabled=True,
1266
- budget=int(flattened.get('thinkingBudget', 2048)),
1267
- effort=flattened.get('reasoningEffort', 'medium'),
1268
- level=flattened.get('thinkingLevel', 'medium'),
1269
- format=flattened.get('reasoningFormat', 'parsed'),
1270
- )
1271
- logger.info(f"[LangGraph] Thinking enabled: budget={thinking_config.budget}, effort={thinking_config.effort}")
1272
-
1273
- # Broadcast: Initializing model
1274
- await broadcast_status("initializing", {
1275
- "message": f"Initializing {provider} model...",
1276
- "provider": provider,
1277
- "model": model
1278
- })
1279
-
1280
- # Create LLM using the provider from node configuration
1281
- logger.debug(f"[LangGraph] Creating {provider} model: {model}")
1282
- chat_model = self.create_model(provider, api_key, model, temperature, max_tokens, thinking_config)
1283
-
1284
- # Build initial messages for state
1285
- initial_messages: List[BaseMessage] = []
1286
- if system_message:
1287
- initial_messages.append(SystemMessage(content=system_message))
1288
-
1289
- # Add memory history from connected simpleMemory node (markdown-based)
1290
- session_id = None
1291
- history_count = 0
1292
- if memory_data and memory_data.get('session_id'):
1293
- session_id = memory_data['session_id']
1294
- memory_content = memory_data.get('memory_content', '')
1295
-
1296
- # Broadcast: Loading memory
1297
- await broadcast_status("loading_memory", {
1298
- "message": f"Loading conversation history...",
1299
- "session_id": session_id,
1300
- "has_memory": True
1301
- })
1302
-
1303
- # Parse short-term memory from markdown
1304
- history_messages = _parse_memory_markdown(memory_content)
1305
- history_count = len(history_messages)
1306
-
1307
- # If long-term memory enabled, retrieve relevant context
1308
- if memory_data.get('long_term_enabled'):
1309
- store = _get_memory_vector_store(session_id)
1310
- if store:
1311
- try:
1312
- k = memory_data.get('retrieval_count', 3)
1313
- docs = store.similarity_search(prompt, k=k)
1314
- if docs:
1315
- context = "\n---\n".join(d.page_content for d in docs)
1316
- initial_messages.append(SystemMessage(content=f"Relevant past context:\n{context}"))
1317
- logger.info(f"[LangGraph Memory] Retrieved {len(docs)} relevant memories from long-term store")
1318
- except Exception as e:
1319
- logger.debug(f"[LangGraph Memory] Long-term retrieval skipped: {e}")
1320
-
1321
- # Add parsed history messages
1322
- initial_messages.extend(history_messages)
1323
-
1324
- logger.info(f"[LangGraph Memory] Loaded {history_count} messages from markdown")
1325
-
1326
- # Broadcast: Memory loaded
1327
- await broadcast_status("memory_loaded", {
1328
- "message": f"Loaded {history_count} messages from memory",
1329
- "session_id": session_id,
1330
- "history_count": history_count
1331
- })
1332
-
1333
- # Add current user prompt
1334
- initial_messages.append(HumanMessage(content=prompt))
1335
-
1336
- # Build tools if provided
1337
- tools = []
1338
- tool_configs = {}
1339
-
1340
- if tool_data:
1341
- await broadcast_status("building_tools", {
1342
- "message": f"Building {len(tool_data)} tool(s)...",
1343
- "tool_count": len(tool_data)
1344
- })
1345
-
1346
- for tool_info in tool_data:
1347
- tool, config = await self._build_tool_from_node(tool_info)
1348
- if tool:
1349
- tools.append(tool)
1350
- tool_configs[tool.name] = config
1351
- logger.info(f"[LangGraph] Registered tool: name={tool.name}, node_id={config.get('node_id')}")
1352
-
1353
- logger.debug(f"[LangGraph] Built {len(tools)} tools")
1354
-
1355
- # Create tool executor callback
1356
- async def tool_executor(tool_name: str, tool_args: Dict) -> Any:
1357
- """Execute a tool by name."""
1358
- from services.handlers.tools import execute_tool
1359
-
1360
- config = tool_configs.get(tool_name, {})
1361
- tool_node_id = config.get('node_id')
1362
-
1363
- logger.info(f"[LangGraph] tool_executor called: tool_name={tool_name}, node_id={tool_node_id}, workflow_id={workflow_id}")
1364
-
1365
- # Broadcast executing status to the AI Agent node
1366
- await broadcast_status("executing_tool", {
1367
- "message": f"Executing tool: {tool_name}",
1368
- "tool_name": tool_name,
1369
- "tool_args": tool_args
1370
- })
1371
-
1372
- # Also broadcast executing status directly to the tool node so it glows
1373
- if tool_node_id and broadcaster:
1374
- await broadcaster.update_node_status(
1375
- tool_node_id,
1376
- "executing",
1377
- {"message": f"Executing {tool_name}"},
1378
- workflow_id=workflow_id
1379
- )
1380
-
1381
- # Include workflow_id in config so tool handlers can broadcast with proper scoping
1382
- config['workflow_id'] = workflow_id
1383
-
1384
- try:
1385
- result = await execute_tool(tool_name, tool_args, config)
1386
-
1387
- # Broadcast completion to AI Agent node
1388
- await broadcast_status("tool_completed", {
1389
- "message": f"Tool completed: {tool_name}",
1390
- "tool_name": tool_name,
1391
- "result_preview": str(result)[:100]
1392
- })
1393
-
1394
- # Broadcast success status to the tool node
1395
- if tool_node_id and broadcaster:
1396
- logger.info(f"[LangGraph] Broadcasting success to tool node: node_id={tool_node_id}, workflow_id={workflow_id}")
1397
- await broadcaster.update_node_status(
1398
- tool_node_id,
1399
- "success",
1400
- {"message": f"{tool_name} completed", "result": result},
1401
- workflow_id=workflow_id
1402
- )
1403
- else:
1404
- logger.warning(f"[LangGraph] Cannot broadcast success: tool_node_id={tool_node_id}, broadcaster={broadcaster is not None}")
1405
-
1406
- return result
1407
-
1408
- except Exception as e:
1409
- error_msg = str(e)
1410
- logger.error(f"[LangGraph] Tool execution failed: {tool_name}", error=error_msg)
1411
-
1412
- # Broadcast error status to the tool node so UI shows failure
1413
- if tool_node_id and broadcaster:
1414
- await broadcaster.update_node_status(
1415
- tool_node_id,
1416
- "error",
1417
- {"message": f"{tool_name} failed", "error": error_msg},
1418
- workflow_id=workflow_id
1419
- )
1420
-
1421
- # Re-raise to let LangGraph handle the error
1422
- raise
1423
-
1424
- # Broadcast: Building graph
1425
- await broadcast_status("building_graph", {
1426
- "message": "Building LangGraph agent...",
1427
- "message_count": len(initial_messages),
1428
- "has_memory": bool(session_id),
1429
- "history_count": history_count,
1430
- "tool_count": len(tools)
1431
- })
1432
-
1433
- # Build and execute LangGraph agent
1434
- logger.debug(f"[LangGraph] Building agent graph with {len(initial_messages)} messages")
1435
- agent_graph = build_agent_graph(
1436
- chat_model,
1437
- tools=tools if tools else None,
1438
- tool_executor=tool_executor if tools else None
1439
- )
1440
-
1441
- # Create initial state with thinking_content for reasoning models
1442
- initial_state: AgentState = {
1443
- "messages": initial_messages,
1444
- "tool_outputs": {},
1445
- "pending_tool_calls": [],
1446
- "iteration": 0,
1447
- "max_iterations": 10,
1448
- "should_continue": False,
1449
- "thinking_content": None
1450
- }
1451
-
1452
- # Broadcast: Executing graph
1453
- await broadcast_status("invoking_llm", {
1454
- "message": f"Invoking {provider} LLM...",
1455
- "provider": provider,
1456
- "model": model,
1457
- "iteration": 1,
1458
- "has_memory": bool(session_id),
1459
- "history_count": history_count
1460
- })
1461
-
1462
- # Execute the graph using ainvoke for proper async support
1463
- # This allows async tool nodes and WebSocket broadcasts to work correctly
1464
- final_state = await agent_graph.ainvoke(initial_state)
1465
-
1466
- # Extract the AI response (last message in the accumulated messages)
1467
- all_messages = final_state["messages"]
1468
- ai_response = all_messages[-1] if all_messages else None
1469
-
1470
- if not ai_response or not hasattr(ai_response, 'content'):
1471
- raise ValueError("No response generated from agent")
1472
-
1473
- # Handle different content formats (Gemini can return list of content blocks)
1474
- raw_content = ai_response.content
1475
- response_content = self._extract_text_content(raw_content, ai_response)
1476
- iterations = final_state.get("iteration", 1)
1477
-
1478
- # Get accumulated thinking content from state
1479
- thinking_content = final_state.get("thinking_content")
1480
-
1481
- logger.info(f"[LangGraph] Agent completed in {iterations} iteration(s), thinking={'yes' if thinking_content else 'no'}")
1482
-
1483
- # Save to memory if connected (markdown-based with optional vector DB)
1484
- # Only save non-empty messages using standardized validation
1485
- if memory_data and memory_data.get('node_id') and is_valid_message_content(prompt) and is_valid_message_content(response_content):
1486
- # Broadcast: Saving to memory
1487
- await broadcast_status("saving_memory", {
1488
- "message": "Saving to conversation memory...",
1489
- "session_id": session_id,
1490
- "has_memory": True,
1491
- "history_count": history_count
1492
- })
1493
-
1494
- # Update markdown content
1495
- updated_content = memory_data.get('memory_content', '# Conversation History\n\n*No messages yet.*\n')
1496
- updated_content = _append_to_memory_markdown(updated_content, 'human', prompt)
1497
- updated_content = _append_to_memory_markdown(updated_content, 'ai', response_content)
1498
-
1499
- # Trim to window size, archive removed to vector DB
1500
- window_size = memory_data.get('window_size', 10)
1501
- updated_content, removed_texts = _trim_markdown_window(updated_content, window_size)
1502
-
1503
- # Store removed messages in long-term vector DB
1504
- if removed_texts and memory_data.get('long_term_enabled'):
1505
- store = _get_memory_vector_store(session_id)
1506
- if store:
1507
- try:
1508
- store.add_texts(removed_texts)
1509
- logger.info(f"[LangGraph Memory] Archived {len(removed_texts)} messages to long-term store")
1510
- except Exception as e:
1511
- logger.warning(f"[LangGraph Memory] Failed to archive to vector store: {e}")
1512
-
1513
- # Save updated markdown to node parameters
1514
- memory_node_id = memory_data['node_id']
1515
- current_params = await self.database.get_node_parameters(memory_node_id) or {}
1516
- current_params['memoryContent'] = updated_content
1517
- await self.database.save_node_parameters(memory_node_id, current_params)
1518
- logger.info(f"[LangGraph Memory] Saved markdown to memory node '{memory_node_id}'")
1519
-
1520
- result = {
1521
- "response": response_content,
1522
- "thinking": thinking_content,
1523
- "thinking_enabled": thinking_config.enabled if thinking_config else False,
1524
- "model": model,
1525
- "provider": provider,
1526
- "agent_type": "langgraph",
1527
- "iterations": iterations,
1528
- "finish_reason": "stop",
1529
- "timestamp": datetime.now().isoformat(),
1530
- "input": {
1531
- "prompt": prompt,
1532
- "system_message": system_message,
1533
- }
1534
- }
1535
-
1536
- # Add memory info if used
1537
- if session_id:
1538
- result["memory"] = {
1539
- "session_id": session_id,
1540
- "history_loaded": history_count
1541
- }
1542
-
1543
- log_execution_time(logger, "ai_agent_langgraph", start_time, time.time())
1544
- log_api_call(logger, provider, model, "agent", True)
1545
-
1546
- return {
1547
- "success": True,
1548
- "node_id": node_id,
1549
- "node_type": "aiAgent",
1550
- "result": result,
1551
- "execution_time": time.time() - start_time
1552
- }
1553
-
1554
- except Exception as e:
1555
- logger.error("[LangGraph] AI agent execution failed", node_id=node_id, error=str(e))
1556
- log_api_call(logger, provider, model, "agent", False, error=str(e))
1557
-
1558
- return {
1559
- "success": False,
1560
- "node_id": node_id,
1561
- "node_type": "aiAgent",
1562
- "error": str(e),
1563
- "execution_time": time.time() - start_time,
1564
- "timestamp": datetime.now().isoformat()
1565
- }
1566
-
1567
- async def execute_chat_agent(self, node_id: str, parameters: Dict[str, Any],
1568
- memory_data: Optional[Dict[str, Any]] = None,
1569
- skill_data: Optional[List[Dict[str, Any]]] = None,
1570
- tool_data: Optional[List[Dict[str, Any]]] = None,
1571
- broadcaster=None,
1572
- workflow_id: Optional[str] = None) -> Dict[str, Any]:
1573
- """Execute Chat Agent - conversational AI with memory, skills, and tool calling.
1574
-
1575
- Chat Agent supports:
1576
- - Memory (input-memory): Markdown-based conversation history (same as AI Agent)
1577
- - Skills (input-skill): Provide context/instructions via SKILL.md
1578
- - Tools (input-tools): Tool nodes (httpRequest, etc.) for LangGraph tool calling
1579
-
1580
- Args:
1581
- node_id: The node identifier
1582
- parameters: Node parameters including prompt, model, etc.
1583
- memory_data: Optional memory data from connected SimpleMemory node (markdown-based)
1584
- skill_data: Optional skill configurations from connected skill nodes
1585
- tool_data: Optional tool configurations from connected tool nodes (httpRequest, etc.)
1586
- broadcaster: Optional StatusBroadcaster for real-time UI updates
1587
- workflow_id: Optional workflow ID for scoped status broadcasts
1588
- """
1589
- start_time = time.time()
1590
- provider = 'unknown'
1591
- model = 'unknown'
1592
-
1593
- logger.info(f"[ChatAgent] execute_chat_agent called: node_id={node_id}, workflow_id={workflow_id}, skill_count={len(skill_data) if skill_data else 0}, tool_count={len(tool_data) if tool_data else 0}")
1594
-
1595
- async def broadcast_status(phase: str, details: Dict[str, Any] = None):
1596
- if broadcaster:
1597
- await broadcaster.update_node_status(node_id, "executing", {
1598
- "phase": phase,
1599
- "agent_type": "chat_with_skills" if skill_data else "chat",
1600
- **(details or {})
1601
- }, workflow_id=workflow_id)
1602
-
1603
- try:
1604
- # Extract parameters
1605
- prompt = parameters.get('prompt', 'Hello')
1606
- system_message = parameters.get('systemMessage', 'You are a helpful assistant')
1607
-
1608
- # Load skills and enhance system message with SKILL.md context
1609
- # Skills only provide instructions/context - actual tools come from direct tool nodes
1610
- if skill_data:
1611
- from services.skill_loader import get_skill_loader
1612
-
1613
- skill_loader = get_skill_loader()
1614
- skill_loader.scan_skills()
1615
-
1616
- # Extract skill names from connected skill nodes
1617
- skill_names = []
1618
- for skill_info in skill_data:
1619
- skill_name = skill_info.get('skill_name') or skill_info.get('node_type', '').replace('Skill', '-skill').lower()
1620
- # Convert node type to skill name (e.g., 'whatsappSkill' -> 'whatsapp-skill')
1621
- if skill_name.endswith('skill') and not '-' in skill_name:
1622
- skill_name = skill_name[:-5] + '-skill' # whatsappskill -> whatsapp-skill
1623
- skill_names.append(skill_name)
1624
- logger.debug(f"[ChatAgent] Skill detected: {skill_name}")
1625
-
1626
- # Add skill SKILL.md content to system message
1627
- skill_prompt = skill_loader.get_registry_prompt(skill_names)
1628
- if skill_prompt:
1629
- system_message = f"{system_message}\n\n{skill_prompt}"
1630
- logger.info(f"[ChatAgent] Enhanced system message with {len(skill_names)} skill contexts")
1631
-
1632
- # Build tools from tool_data using same method as AI Agent
1633
- # This supports ALL tool types: calculatorTool, currentTimeTool, webSearchTool, androidTool, httpRequest
1634
- all_tools = []
1635
- tool_node_configs = {} # Map tool name to node config (same as AI Agent's tool_configs)
1636
- if tool_data:
1637
- await broadcast_status("building_tools", {
1638
- "message": f"Building {len(tool_data)} tool(s)...",
1639
- "tool_count": len(tool_data)
1640
- })
1641
-
1642
- for tool_info in tool_data:
1643
- # Use AI Agent's _build_tool_from_node for all tool types
1644
- tool, config = await self._build_tool_from_node(tool_info)
1645
- if tool:
1646
- all_tools.append(tool)
1647
- tool_node_configs[tool.name] = config
1648
- logger.info(f"[ChatAgent] Built tool: {tool.name} (type={config.get('node_type')}, node_id={config.get('node_id')})")
1649
-
1650
- logger.info(f"[ChatAgent] Built {len(all_tools)} tools from tool_data")
1651
-
1652
- logger.info(f"[ChatAgent] Total tools available: {len(all_tools)}")
1653
-
1654
- # Flatten options collection from frontend
1655
- options = parameters.get('options', {})
1656
- flattened = {**parameters, **options}
1657
-
1658
- api_key = flattened.get('api_key') or flattened.get('apiKey')
1659
- provider = parameters.get('provider', 'openai')
1660
- model = parameters.get('model', '')
1661
- temperature = float(flattened.get('temperature', 0.7))
1662
- max_tokens = int(flattened.get('max_tokens') or flattened.get('maxTokens') or 1000)
1663
-
1664
- logger.info(f"[ChatAgent] Provider: {provider}, Model: {model}")
1665
-
1666
- # Validate model for provider
1667
- if not model or not is_model_valid_for_provider(model, provider):
1668
- old_model = model
1669
- model = get_default_model(provider)
1670
- if old_model:
1671
- logger.warning(f"Model '{old_model}' invalid for provider '{provider}', using default: {model}")
1672
- else:
1673
- logger.info(f"No model specified, using default: {model}")
1674
-
1675
- if not api_key:
1676
- raise ValueError("API key is required for Chat Agent")
1677
-
1678
- # Build thinking config from parameters
1679
- thinking_config = None
1680
- if flattened.get('thinkingEnabled'):
1681
- thinking_config = ThinkingConfig(
1682
- enabled=True,
1683
- budget=int(flattened.get('thinkingBudget', 2048)),
1684
- effort=flattened.get('reasoningEffort', 'medium'),
1685
- level=flattened.get('thinkingLevel', 'medium'),
1686
- format=flattened.get('reasoningFormat', 'parsed'),
1687
- )
1688
-
1689
- # Broadcast: Initializing
1690
- await broadcast_status("initializing", {
1691
- "message": f"Initializing {provider} model...",
1692
- "provider": provider,
1693
- "model": model
1694
- })
1695
-
1696
- # Create chat model
1697
- chat_model = self.create_model(provider, api_key, model, temperature, max_tokens, thinking_config)
1698
-
1699
- # Build messages
1700
- messages: List[BaseMessage] = []
1701
- if system_message:
1702
- messages.append(SystemMessage(content=system_message))
1703
-
1704
- # Load memory history if connected (markdown-based like AI Agent)
1705
- session_id = None
1706
- history_count = 0
1707
- memory_content = None
1708
- if memory_data and memory_data.get('node_id'):
1709
- session_id = memory_data.get('session_id', 'default')
1710
- memory_content = memory_data.get('memory_content', '# Conversation History\n\n*No messages yet.*\n')
1711
-
1712
- await broadcast_status("loading_memory", {
1713
- "message": "Loading conversation history...",
1714
- "session_id": session_id,
1715
- "has_memory": True
1716
- })
1717
-
1718
- # Parse short-term memory from markdown
1719
- history_messages = _parse_memory_markdown(memory_content)
1720
- history_count = len(history_messages)
1721
-
1722
- # If long-term memory enabled, retrieve relevant context
1723
- if memory_data.get('long_term_enabled'):
1724
- store = _get_memory_vector_store(session_id)
1725
- if store:
1726
- try:
1727
- k = memory_data.get('retrieval_count', 3)
1728
- docs = store.similarity_search(prompt, k=k)
1729
- if docs:
1730
- context = "\n---\n".join(d.page_content for d in docs)
1731
- messages.append(SystemMessage(content=f"Relevant past context:\n{context}"))
1732
- logger.info(f"[ChatAgent Memory] Retrieved {len(docs)} relevant memories from long-term store")
1733
- except Exception as e:
1734
- logger.debug(f"[ChatAgent Memory] Long-term retrieval skipped: {e}")
1735
-
1736
- # Add parsed history messages
1737
- messages.extend(history_messages)
1738
-
1739
- logger.info(f"[ChatAgent Memory] Loaded {history_count} messages from markdown")
1740
-
1741
- await broadcast_status("memory_loaded", {
1742
- "message": f"Loaded {history_count} messages from memory",
1743
- "session_id": session_id,
1744
- "history_count": history_count
1745
- })
1746
-
1747
- # Add current prompt
1748
- messages.append(HumanMessage(content=prompt))
1749
-
1750
- # Broadcast: Invoking LLM
1751
- await broadcast_status("invoking_llm", {
1752
- "message": "Generating response...",
1753
- "has_memory": session_id is not None,
1754
- "history_count": history_count,
1755
- "skill_count": len(skill_data) if skill_data else 0
1756
- })
1757
-
1758
- # Execute with or without tools
1759
- thinking_content = None
1760
- iterations = 1
1761
-
1762
- if all_tools:
1763
- # Use LangGraph for tool execution (like AI Agent)
1764
- logger.info(f"[ChatAgent] Using LangGraph with {len(all_tools)} tools")
1765
-
1766
- # Create tool executor callback - same pattern as AI Agent
1767
- # Uses handlers/tools.py execute_tool() for actual execution
1768
- async def chat_tool_executor(tool_name: str, tool_args: Dict) -> Any:
1769
- """Execute a tool by name using handlers/tools.py (same as AI Agent)."""
1770
- from services.handlers.tools import execute_tool
1771
-
1772
- logger.info(f"[ChatAgent] Executing tool: {tool_name}, args={tool_args}")
1773
-
1774
- # Get tool node config (contains node_id, node_type, parameters)
1775
- config = tool_node_configs.get(tool_name, {})
1776
- tool_node_id = config.get('node_id')
1777
-
1778
- # Broadcast executing status to tool node for glow effect
1779
- if tool_node_id and broadcaster:
1780
- await broadcaster.update_node_status(
1781
- tool_node_id,
1782
- "executing",
1783
- {"message": f"Executing {tool_name}"},
1784
- workflow_id=workflow_id
1785
- )
1786
-
1787
- try:
1788
- # Execute via handlers/tools.py - same pattern as AI Agent
1789
- result = await execute_tool(tool_name, tool_args, config)
1790
- logger.info(f"[ChatAgent] Tool executed successfully: {tool_name}")
1791
-
1792
- # Broadcast success to tool node
1793
- if tool_node_id and broadcaster:
1794
- await broadcaster.update_node_status(
1795
- tool_node_id,
1796
- "success",
1797
- {"message": f"{tool_name} completed", "result": result},
1798
- workflow_id=workflow_id
1799
- )
1800
- return result
1801
-
1802
- except Exception as e:
1803
- logger.error(f"[ChatAgent] Tool execution failed: {tool_name}", error=str(e))
1804
- # Broadcast error to tool node
1805
- if tool_node_id and broadcaster:
1806
- await broadcaster.update_node_status(
1807
- tool_node_id,
1808
- "error",
1809
- {"message": f"{tool_name} failed", "error": str(e)},
1810
- workflow_id=workflow_id
1811
- )
1812
- return {"error": str(e)}
1813
-
1814
- # Build LangGraph agent with all tools
1815
- agent_graph = build_agent_graph(
1816
- chat_model,
1817
- tools=all_tools,
1818
- tool_executor=chat_tool_executor
1819
- )
1820
-
1821
- # Create initial state
1822
- initial_state: AgentState = {
1823
- "messages": messages,
1824
- "tool_outputs": {},
1825
- "pending_tool_calls": [],
1826
- "iteration": 0,
1827
- "max_iterations": 10,
1828
- "should_continue": False,
1829
- "thinking_content": None
1830
- }
1831
-
1832
- # Execute the graph
1833
- final_state = await agent_graph.ainvoke(initial_state)
1834
-
1835
- # Extract response
1836
- all_messages = final_state["messages"]
1837
- ai_response = all_messages[-1] if all_messages else None
1838
-
1839
- if not ai_response or not hasattr(ai_response, 'content'):
1840
- raise ValueError("No response generated from agent")
1841
-
1842
- raw_content = ai_response.content
1843
- response_content = self._extract_text_content(raw_content, ai_response)
1844
- iterations = final_state.get("iteration", 1)
1845
- thinking_content = final_state.get("thinking_content")
1846
- else:
1847
- # Simple invoke without tools
1848
- response = await chat_model.ainvoke(messages)
1849
-
1850
- # Extract response content
1851
- raw_content = response.content
1852
- response_content = self._extract_text_content(raw_content, response)
1853
-
1854
- # Extract thinking content if available
1855
- _, thinking_content = extract_thinking_from_response(response)
1856
-
1857
- logger.info(f"[ChatAgent] Response generated, thinking={'yes' if thinking_content else 'no'}, iterations={iterations}")
1858
-
1859
- # Save to memory if connected (markdown-based like AI Agent)
1860
- if memory_data and memory_data.get('node_id') and is_valid_message_content(prompt) and is_valid_message_content(response_content):
1861
- await broadcast_status("saving_memory", {
1862
- "message": "Saving to conversation memory...",
1863
- "session_id": session_id,
1864
- "has_memory": True
1865
- })
1866
-
1867
- # Update markdown content
1868
- updated_content = memory_content or '# Conversation History\n\n*No messages yet.*\n'
1869
- updated_content = _append_to_memory_markdown(updated_content, 'human', prompt)
1870
- updated_content = _append_to_memory_markdown(updated_content, 'ai', response_content)
1871
-
1872
- # Trim to window size, archive removed to vector DB
1873
- window_size = memory_data.get('window_size', 10)
1874
- updated_content, removed_texts = _trim_markdown_window(updated_content, window_size)
1875
-
1876
- # Store removed messages in long-term vector DB
1877
- if removed_texts and memory_data.get('long_term_enabled'):
1878
- store = _get_memory_vector_store(session_id)
1879
- if store:
1880
- try:
1881
- store.add_texts(removed_texts)
1882
- logger.info(f"[ChatAgent Memory] Archived {len(removed_texts)} messages to long-term store")
1883
- except Exception as e:
1884
- logger.warning(f"[ChatAgent Memory] Failed to archive to vector store: {e}")
1885
-
1886
- # Save updated markdown to node parameters
1887
- memory_node_id = memory_data['node_id']
1888
- current_params = await self.database.get_node_parameters(memory_node_id) or {}
1889
- current_params['memoryContent'] = updated_content
1890
- await self.database.save_node_parameters(memory_node_id, current_params)
1891
- logger.info(f"[ChatAgent Memory] Saved markdown to memory node '{memory_node_id}'")
1892
-
1893
- # Determine agent type based on configuration
1894
- agent_type = "chat"
1895
- if skill_data and all_tools:
1896
- agent_type = "chat_with_skills_and_tools"
1897
- elif skill_data:
1898
- agent_type = "chat_with_skills"
1899
- elif all_tools:
1900
- agent_type = "chat_with_tools"
1901
-
1902
- result = {
1903
- "response": response_content,
1904
- "thinking": thinking_content,
1905
- "thinking_enabled": thinking_config.enabled if thinking_config else False,
1906
- "model": model,
1907
- "provider": provider,
1908
- "agent_type": agent_type,
1909
- "iterations": iterations,
1910
- "finish_reason": "stop",
1911
- "timestamp": datetime.now().isoformat(),
1912
- "input": {
1913
- "prompt": prompt,
1914
- "system_message": system_message,
1915
- }
1916
- }
1917
-
1918
- if session_id:
1919
- result["memory"] = {
1920
- "session_id": session_id,
1921
- "history_loaded": history_count
1922
- }
1923
-
1924
- if skill_data:
1925
- result["skills"] = {
1926
- "connected": [s.get('skill_name', s.get('node_type', '')) for s in skill_data],
1927
- "count": len(skill_data)
1928
- }
1929
-
1930
- if all_tools:
1931
- result["tools"] = {
1932
- "connected": [t.name for t in all_tools],
1933
- "count": len(all_tools)
1934
- }
1935
-
1936
- log_execution_time(logger, "chat_agent", start_time, time.time())
1937
- log_api_call(logger, provider, model, "chat_agent", True)
1938
-
1939
- # Save assistant response to chat messages database for console panel persistence
1940
- try:
1941
- await self.database.add_chat_message("default", "assistant", response_content)
1942
- except Exception as e:
1943
- logger.warning(f"[ChatAgent] Failed to save chat response to database: {e}")
1944
-
1945
- return {
1946
- "success": True,
1947
- "node_id": node_id,
1948
- "node_type": "chatAgent",
1949
- "result": result,
1950
- "execution_time": time.time() - start_time
1951
- }
1952
-
1953
- except Exception as e:
1954
- logger.error("[ChatAgent] Execution failed", node_id=node_id, error=str(e))
1955
- log_api_call(logger, provider, model, "chat_agent", False, error=str(e))
1956
-
1957
- return {
1958
- "success": False,
1959
- "node_id": node_id,
1960
- "node_type": "chatAgent",
1961
- "error": str(e),
1962
- "execution_time": time.time() - start_time,
1963
- "timestamp": datetime.now().isoformat()
1964
- }
1965
-
1966
- async def _build_tool_from_node(self, tool_info: Dict[str, Any]) -> tuple:
1967
- """Convert a node configuration into a LangChain StructuredTool.
1968
-
1969
- Uses database-stored schema as source of truth if available, otherwise
1970
- falls back to dynamic schema generation.
1971
-
1972
- Args:
1973
- tool_info: Dict containing node_id, node_type, parameters, label, connected_services (for androidTool)
1974
-
1975
- Returns:
1976
- Tuple of (StructuredTool, config_dict) or (None, None) on failure
1977
- """
1978
- # Default tool names matching frontend toolNodes.ts definitions
1979
- DEFAULT_TOOL_NAMES = {
1980
- 'calculatorTool': 'calculator',
1981
- 'currentTimeTool': 'get_current_time',
1982
- 'webSearchTool': 'web_search',
1983
- 'androidTool': 'android_device',
1984
- 'whatsappSend': 'whatsapp_send',
1985
- 'whatsappDb': 'whatsapp_db',
1986
- 'addLocations': 'geocode',
1987
- 'showNearbyPlaces': 'nearby_places',
1988
- }
1989
- DEFAULT_TOOL_DESCRIPTIONS = {
1990
- 'calculatorTool': 'Perform mathematical calculations. Operations: add, subtract, multiply, divide, power, sqrt, mod, abs',
1991
- 'currentTimeTool': 'Get the current date and time. Optionally specify timezone.',
1992
- 'webSearchTool': 'Search the web for information. Returns relevant search results.',
1993
- 'androidTool': 'Control Android device. Available services are determined by connected nodes.',
1994
- 'whatsappSend': 'Send WhatsApp messages to contacts or groups. Supports text, media, location, and contact messages.',
1995
- 'whatsappDb': 'Query WhatsApp database - list contacts, search groups, get contact/group info, retrieve chat history.',
1996
- 'addLocations': 'Geocode addresses to coordinates or reverse geocode coordinates to addresses using Google Maps.',
1997
- 'showNearbyPlaces': 'Search for nearby places (restaurants, hospitals, banks, etc.) using Google Maps Places API.',
1998
- }
1999
-
2000
- try:
2001
- node_type = tool_info.get('node_type', '')
2002
- node_params = tool_info.get('parameters', {})
2003
- node_label = tool_info.get('label', node_type)
2004
- node_id = tool_info.get('node_id', '')
2005
- connected_services = tool_info.get('connected_services', [])
2006
-
2007
- # Check database for stored schema (source of truth)
2008
- db_schema = await self.database.get_tool_schema(node_id) if node_id else None
2009
-
2010
- if db_schema:
2011
- # Use database schema as source of truth
2012
- logger.debug(f"[LangGraph] Using DB schema for tool node {node_id}")
2013
- tool_name = db_schema.get('tool_name', DEFAULT_TOOL_NAMES.get(node_type, f"tool_{node_label}"))
2014
- tool_description = db_schema.get('tool_description', DEFAULT_TOOL_DESCRIPTIONS.get(node_type, f"Execute {node_label}"))
2015
- # Use stored connected_services if available (for toolkit nodes)
2016
- if db_schema.get('connected_services'):
2017
- connected_services = db_schema['connected_services']
2018
- else:
2019
- # Fall back to dynamic generation from node params
2020
- tool_name = (
2021
- node_params.get('toolName') or
2022
- DEFAULT_TOOL_NAMES.get(node_type) or
2023
- f"tool_{node_label}".replace(' ', '_').replace('-', '_').lower()
2024
- )
2025
- tool_description = (
2026
- node_params.get('toolDescription') or
2027
- DEFAULT_TOOL_DESCRIPTIONS.get(node_type) or
2028
- f"Execute {node_label} node"
2029
- )
2030
-
2031
- # For androidTool, enhance description with connected services
2032
- if node_type == 'androidTool' and connected_services:
2033
- service_names = [s.get('label') or s.get('service_id', 'unknown') for s in connected_services]
2034
- tool_description = f"{tool_description} Connected: {', '.join(service_names)}"
2035
-
2036
- # Clean tool name (LangChain requires alphanumeric + underscores)
2037
- import re
2038
- tool_name = re.sub(r'[^a-zA-Z0-9_]', '_', tool_name)
2039
-
2040
- # Build schema based on node type - pass connected_services for androidTool
2041
- # If DB has schema_config, use it to build custom schema, otherwise use dynamic
2042
- schema_params = dict(node_params)
2043
- if connected_services:
2044
- schema_params['connected_services'] = connected_services
2045
- if db_schema and db_schema.get('schema_config'):
2046
- schema_params['db_schema_config'] = db_schema['schema_config']
2047
- schema = self._get_tool_schema(node_type, schema_params)
2048
-
2049
- # Create StructuredTool - the func is a placeholder, actual execution via tool_executor
2050
- def placeholder_func(**kwargs):
2051
- return kwargs
2052
-
2053
- tool = StructuredTool.from_function(
2054
- name=tool_name,
2055
- description=tool_description,
2056
- func=placeholder_func,
2057
- args_schema=schema
2058
- )
2059
-
2060
- # Build config dict - include connected_services for toolkit nodes
2061
- config = {
2062
- 'node_type': node_type,
2063
- 'node_id': node_id,
2064
- 'parameters': node_params,
2065
- 'label': node_label,
2066
- 'connected_services': connected_services # Pass through for execution
2067
- }
2068
-
2069
- logger.debug(f"[LangGraph] Built tool '{tool_name}' with node_id={node_id}")
2070
- return tool, config
2071
-
2072
- except Exception as e:
2073
- logger.error(f"[LangGraph] Failed to build tool from node: {e}")
2074
- return None, None
2075
-
2076
- def _get_tool_schema(self, node_type: str, params: Dict[str, Any]) -> Type[BaseModel]:
2077
- """Get Pydantic schema for tool based on node type.
2078
-
2079
- Uses db_schema_config from database if available (source of truth),
2080
- otherwise falls back to built-in schema definitions.
2081
-
2082
- Args:
2083
- node_type: The node type (e.g., 'calculatorTool', 'httpRequest')
2084
- params: Node parameters, may include db_schema_config from database
2085
-
2086
- Returns:
2087
- Pydantic BaseModel class for the tool's arguments
2088
- """
2089
- # Check if we have a database-stored schema config (source of truth)
2090
- db_schema_config = params.get('db_schema_config')
2091
- if db_schema_config:
2092
- return self._build_schema_from_config(db_schema_config)
2093
-
2094
- # Calculator tool schema
2095
- if node_type == 'calculatorTool':
2096
- class CalculatorSchema(BaseModel):
2097
- """Schema for calculator tool arguments."""
2098
- operation: str = Field(
2099
- description="Math operation: add, subtract, multiply, divide, power, sqrt, mod, abs"
2100
- )
2101
- a: float = Field(description="First number")
2102
- b: float = Field(default=0, description="Second number (not needed for sqrt, abs)")
2103
-
2104
- return CalculatorSchema
2105
-
2106
- # HTTP Request tool schema
2107
- if node_type in ('httpRequest', 'httpRequestTool'):
2108
- class HttpRequestSchema(BaseModel):
2109
- """Schema for HTTP request tool arguments."""
2110
- url: str = Field(description="URL path or full URL to request")
2111
- method: str = Field(default="GET", description="HTTP method: GET, POST, PUT, DELETE")
2112
- body: Optional[Dict[str, Any]] = Field(default=None, description="Request body as JSON object")
2113
-
2114
- return HttpRequestSchema
2115
-
2116
- # Python executor tool schema
2117
- if node_type == 'pythonExecutor':
2118
- class PythonCodeSchema(BaseModel):
2119
- """Schema for Python code execution."""
2120
- code: str = Field(description="Python code to execute")
2121
-
2122
- return PythonCodeSchema
2123
-
2124
- # Current time tool schema
2125
- if node_type == 'currentTimeTool':
2126
- class CurrentTimeSchema(BaseModel):
2127
- """Schema for current time tool arguments."""
2128
- timezone: str = Field(
2129
- default="UTC",
2130
- description="Timezone (e.g., UTC, America/New_York, Europe/London)"
2131
- )
2132
-
2133
- return CurrentTimeSchema
2134
-
2135
- # Web search tool schema
2136
- if node_type == 'webSearchTool':
2137
- class WebSearchSchema(BaseModel):
2138
- """Schema for web search tool arguments."""
2139
- query: str = Field(description="Search query to look up on the web")
2140
-
2141
- return WebSearchSchema
2142
-
2143
- # WhatsApp send schema (existing node used as tool)
2144
- if node_type == 'whatsappSend':
2145
- class WhatsAppSendSchema(BaseModel):
2146
- """Send WhatsApp messages to contacts or groups."""
2147
- recipient_type: str = Field(
2148
- default="phone",
2149
- description="Send to: 'phone' for individual or 'group' for group chat"
2150
- )
2151
- phone: Optional[str] = Field(
2152
- default=None,
2153
- description="Phone number without + prefix (e.g., 1234567890). Required for recipient_type='phone'"
2154
- )
2155
- group_id: Optional[str] = Field(
2156
- default=None,
2157
- description="Group JID (e.g., 123456789@g.us). Required for recipient_type='group'"
2158
- )
2159
- message_type: str = Field(
2160
- default="text",
2161
- description="Message type: 'text', 'image', 'video', 'audio', 'document', 'sticker', 'location', 'contact'"
2162
- )
2163
- message: Optional[str] = Field(
2164
- default=None,
2165
- description="Text message content. Required for message_type='text'"
2166
- )
2167
- media_url: Optional[str] = Field(
2168
- default=None,
2169
- description="URL for media (image/video/audio/document/sticker)"
2170
- )
2171
- caption: Optional[str] = Field(
2172
- default=None,
2173
- description="Caption for media messages (image, video, document)"
2174
- )
2175
- latitude: Optional[float] = Field(default=None, description="Latitude for location message")
2176
- longitude: Optional[float] = Field(default=None, description="Longitude for location message")
2177
- location_name: Optional[str] = Field(default=None, description="Display name for location")
2178
- address: Optional[str] = Field(default=None, description="Address text for location")
2179
- contact_name: Optional[str] = Field(default=None, description="Contact card display name")
2180
- vcard: Optional[str] = Field(default=None, description="vCard 3.0 format string for contact")
2181
-
2182
- return WhatsAppSendSchema
2183
-
2184
- # WhatsApp DB schema (existing node used as tool) - query contacts, groups, messages
2185
- if node_type == 'whatsappDb':
2186
- class WhatsAppDbSchema(BaseModel):
2187
- """Query WhatsApp database - contacts, groups, messages.
2188
-
2189
- Operations:
2190
- - chat_history: Get messages from a chat (requires phone or group_id)
2191
- - search_groups: Search groups by name (optional query)
2192
- - get_group_info: Get group details with participant names (requires group_id)
2193
- - get_contact_info: Get full contact info for sending/replying (requires phone)
2194
- - list_contacts: List contacts with saved names (optional query filter)
2195
- - check_contacts: Check WhatsApp registration (requires phones comma-separated)
2196
- """
2197
- operation: str = Field(
2198
- default="chat_history",
2199
- description="Operation: 'chat_history', 'search_groups', 'get_group_info', 'get_contact_info', 'list_contacts', 'check_contacts'"
2200
- )
2201
- # For chat_history
2202
- chat_type: Optional[str] = Field(
2203
- default=None,
2204
- description="For chat_history: 'individual' or 'group'"
2205
- )
2206
- phone: Optional[str] = Field(
2207
- default=None,
2208
- description="Phone number without + prefix. For chat_history (individual), get_contact_info"
2209
- )
2210
- group_id: Optional[str] = Field(
2211
- default=None,
2212
- description="Group JID. For chat_history (group), get_group_info"
2213
- )
2214
- message_filter: Optional[str] = Field(
2215
- default=None,
2216
- description="For chat_history: 'all' or 'text_only'"
2217
- )
2218
- group_filter: Optional[str] = Field(
2219
- default=None,
2220
- description="For chat_history (group): 'all' or 'contact' to filter by sender"
2221
- )
2222
- sender_phone: Optional[str] = Field(
2223
- default=None,
2224
- description="For chat_history (group with group_filter='contact'): filter messages from this phone"
2225
- )
2226
- limit: Optional[int] = Field(
2227
- default=None,
2228
- description="Max results to return. chat_history: 1-500 (default 50), search_groups: 1-50 (default 20), list_contacts: 1-100 (default 50). Use smaller limits to avoid context overflow."
2229
- )
2230
- offset: Optional[int] = Field(default=None, description="For chat_history: pagination offset")
2231
- # For search_groups, list_contacts
2232
- query: Optional[str] = Field(
2233
- default=None,
2234
- description="Search query for search_groups or list_contacts. Use specific queries to narrow results."
2235
- )
2236
- # For check_contacts
2237
- phones: Optional[str] = Field(
2238
- default=None,
2239
- description="For check_contacts: comma-separated phone numbers"
2240
- )
2241
- # For get_group_info
2242
- participant_limit: Optional[int] = Field(
2243
- default=None,
2244
- description="For get_group_info: max participants to return (1-100, default 50). Large groups may have hundreds of members."
2245
- )
2246
-
2247
- return WhatsAppDbSchema
2248
-
2249
- # Android toolkit schema - dynamic based on connected services
2250
- # Follows LangChain dynamic tool binding pattern
2251
- if node_type == 'androidTool':
2252
- connected_services = params.get('connected_services', [])
2253
-
2254
- if not connected_services:
2255
- # No services connected - minimal schema with helpful error
2256
- class EmptyAndroidSchema(BaseModel):
2257
- """Android toolkit with no connected services."""
2258
- query: str = Field(
2259
- default="status",
2260
- description="No Android services connected. Connect Android nodes to the toolkit."
2261
- )
2262
- return EmptyAndroidSchema
2263
-
2264
- # Build dynamic service list for schema description
2265
- from services.android_service import SERVICE_ACTIONS
2266
-
2267
- service_info = []
2268
- for svc in connected_services:
2269
- svc_id = svc.get('service_id') or svc.get('node_type', 'unknown')
2270
- actions = SERVICE_ACTIONS.get(svc_id, [])
2271
- action_list = [a['value'] for a in actions] if actions else ['status']
2272
- service_info.append(f"{svc_id}: {'/'.join(action_list)}")
2273
-
2274
- services_description = "; ".join(service_info)
2275
-
2276
- class AndroidToolSchema(BaseModel):
2277
- """Schema for Android device control via connected services."""
2278
- service_id: str = Field(
2279
- description=f"Service to use. Connected: {services_description}"
2280
- )
2281
- action: str = Field(
2282
- description="Action to perform (see service list for available actions)"
2283
- )
2284
- parameters: Optional[Dict[str, Any]] = Field(
2285
- default=None,
2286
- description="Action parameters. Examples: {package_name: 'com.app'} for app_launcher, {volume: 50} for audio"
2287
- )
2288
-
2289
- return AndroidToolSchema
2290
-
2291
- # Google Maps Geocoding schema (addLocations node as tool)
2292
- # camelCase to match JSON/frontend convention
2293
- if node_type == 'addLocations':
2294
- class GeocodingSchema(BaseModel):
2295
- """Geocode addresses to coordinates or reverse geocode coordinates to addresses."""
2296
- service_type: str = Field(
2297
- default="geocode",
2298
- description="Operation: 'geocode' (address to coordinates) or 'reverse_geocode' (coordinates to address)"
2299
- )
2300
- address: Optional[str] = Field(
2301
- default=None,
2302
- description="Address to geocode (e.g., '1600 Amphitheatre Parkway, Mountain View, CA'). Required for service_type='geocode'"
2303
- )
2304
- lat: Optional[float] = Field(
2305
- default=None,
2306
- description="Latitude for reverse geocoding. Required for service_type='reverse_geocode'"
2307
- )
2308
- lng: Optional[float] = Field(
2309
- default=None,
2310
- description="Longitude for reverse geocoding. Required for service_type='reverse_geocode'"
2311
- )
2312
-
2313
- return GeocodingSchema
2314
-
2315
- # Google Maps Nearby Places schema (showNearbyPlaces node as tool)
2316
- # snake_case to match Python convention
2317
- if node_type == 'showNearbyPlaces':
2318
- class NearbyPlacesSchema(BaseModel):
2319
- """Search for nearby places using Google Maps Places API."""
2320
- lat: float = Field(
2321
- description="Center latitude for search (e.g., 40.7484)"
2322
- )
2323
- lng: float = Field(
2324
- description="Center longitude for search (e.g., -73.9857)"
2325
- )
2326
- radius: int = Field(
2327
- default=500,
2328
- description="Search radius in meters (max 50000)"
2329
- )
2330
- type: str = Field(
2331
- default="restaurant",
2332
- description="Place type: restaurant, cafe, bar, hospital, pharmacy, bank, atm, gas_station, supermarket, park, gym, etc."
2333
- )
2334
- keyword: Optional[str] = Field(
2335
- default=None,
2336
- description="Optional keyword to filter results (e.g., 'pizza', 'italian', '24 hour')"
2337
- )
2338
-
2339
- return NearbyPlacesSchema
2340
-
2341
- # Generic schema for other nodes
2342
- class GenericToolSchema(BaseModel):
2343
- """Generic schema for tool arguments."""
2344
- input: str = Field(description="Input data for the tool")
2345
-
2346
- return GenericToolSchema
2347
-
2348
- def _build_schema_from_config(self, schema_config: Dict[str, Any]) -> Type[BaseModel]:
2349
- """Build a Pydantic schema from database-stored configuration.
2350
-
2351
- Schema config format:
2352
- {
2353
- "description": "Schema description",
2354
- "fields": {
2355
- "field_name": {
2356
- "type": "string" | "number" | "boolean" | "object" | "array",
2357
- "description": "Field description",
2358
- "required": True | False,
2359
- "default": <optional default value>,
2360
- "enum": [<optional enum values>]
2361
- }
2362
- }
2363
- }
2364
- """
2365
- fields_config = schema_config.get('fields', {})
2366
- schema_description = schema_config.get('description', 'Tool arguments schema')
2367
-
2368
- # Build field annotations and defaults
2369
- annotations = {}
2370
- field_defaults = {}
2371
-
2372
- TYPE_MAP = {
2373
- 'string': str,
2374
- 'number': float,
2375
- 'integer': int,
2376
- 'boolean': bool,
2377
- 'object': Dict[str, Any],
2378
- 'array': list,
2379
- }
2380
-
2381
- for field_name, field_config in fields_config.items():
2382
- field_type_str = field_config.get('type', 'string')
2383
- field_type = TYPE_MAP.get(field_type_str, str)
2384
- field_description = field_config.get('description', '')
2385
- is_required = field_config.get('required', True)
2386
- default_value = field_config.get('default')
2387
- enum_values = field_config.get('enum')
2388
-
2389
- # Handle optional fields
2390
- if not is_required:
2391
- field_type = Optional[field_type]
2392
-
2393
- annotations[field_name] = field_type
2394
-
2395
- # Build Field with description and enum if provided
2396
- field_kwargs = {'description': field_description}
2397
- if enum_values:
2398
- # For enums, include in description since Pydantic Field doesn't support enum directly
2399
- field_kwargs['description'] = f"{field_description} Options: {', '.join(str(v) for v in enum_values)}"
2400
-
2401
- if default_value is not None:
2402
- field_defaults[field_name] = Field(default=default_value, **field_kwargs)
2403
- elif not is_required:
2404
- field_defaults[field_name] = Field(default=None, **field_kwargs)
2405
- else:
2406
- field_defaults[field_name] = Field(**field_kwargs)
2407
-
2408
- # Create dynamic Pydantic model
2409
- DynamicSchema = create_model(
2410
- 'DynamicToolSchema',
2411
- __doc__=schema_description,
2412
- **{name: (annotations[name], field_defaults[name]) for name in annotations}
2413
- )
2414
-
1
+ """AI service for managing language models with LangGraph state machine support."""
2
+
3
+ import re
4
+ import time
5
+ import httpx
6
+ from dataclasses import dataclass
7
+ from datetime import datetime
8
+ from typing import Dict, Any, List, Optional, Callable, Type, TypedDict, Annotated, Sequence
9
+ import operator
10
+
11
+ from langchain_openai import ChatOpenAI
12
+ from langchain_anthropic import ChatAnthropic
13
+ from langchain_google_genai import ChatGoogleGenerativeAI
14
+ from langchain_groq import ChatGroq
15
+ from langchain_core.messages import HumanMessage, SystemMessage, AIMessage, BaseMessage, ToolMessage
16
+
17
+ # Conditional import for Cerebras (requires Python <3.13)
18
+ try:
19
+ from langchain_cerebras import ChatCerebras
20
+ CEREBRAS_AVAILABLE = True
21
+ except ImportError:
22
+ ChatCerebras = None
23
+ CEREBRAS_AVAILABLE = False
24
+ from langchain_core.tools import StructuredTool
25
+ from langgraph.graph import StateGraph, END
26
+ from pydantic import BaseModel, Field, create_model
27
+ import json
28
+
29
+ from core.config import Settings
30
+ from core.logging import get_logger, log_execution_time, log_api_call
31
+ from services.auth import AuthService
32
+
33
+ logger = get_logger(__name__)
34
+
35
+
36
+ # =============================================================================
37
+ # MARKDOWN MEMORY HELPERS - Parse/append/trim conversation markdown
38
+ # =============================================================================
39
+
40
+ def _parse_memory_markdown(content: str) -> List[BaseMessage]:
41
+ """Parse markdown memory content into LangChain messages.
42
+
43
+ Markdown format:
44
+ ### **Human** (timestamp)
45
+ message content
46
+
47
+ ### **Assistant** (timestamp)
48
+ response content
49
+ """
50
+ messages = []
51
+ pattern = r'### \*\*(Human|Assistant)\*\*[^\n]*\n(.*?)(?=\n### \*\*|$)'
52
+ for role, text in re.findall(pattern, content, re.DOTALL):
53
+ text = text.strip()
54
+ if text:
55
+ msg_class = HumanMessage if role == 'Human' else AIMessage
56
+ messages.append(msg_class(content=text))
57
+ return messages
58
+
59
+
60
+ def _append_to_memory_markdown(content: str, role: str, message: str) -> str:
61
+ """Append a message to markdown memory content."""
62
+ ts = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
63
+ label = "Human" if role == "human" else "Assistant"
64
+ entry = f"\n### **{label}** ({ts})\n{message}\n"
65
+ # Remove empty state message if present
66
+ return content.replace("*No messages yet.*\n", "") + entry
67
+
68
+
69
+ def _trim_markdown_window(content: str, window_size: int) -> tuple:
70
+ """Keep last N message pairs, return (trimmed_content, removed_texts).
71
+
72
+ Args:
73
+ content: Full markdown content
74
+ window_size: Number of message PAIRS to keep (human+assistant)
75
+
76
+ Returns:
77
+ Tuple of (trimmed markdown, list of removed message texts for archival)
78
+ """
79
+ pattern = r'(### \*\*(Human|Assistant)\*\*[^\n]*\n.*?)(?=\n### \*\*|$)'
80
+ blocks = [m[0] for m in re.findall(pattern, content, re.DOTALL)]
81
+
82
+ if len(blocks) <= window_size * 2:
83
+ return content, []
84
+
85
+ keep = blocks[-(window_size * 2):]
86
+ removed = blocks[:-(window_size * 2)]
87
+
88
+ # Extract text from removed blocks for vector storage
89
+ removed_texts = []
90
+ for block in removed:
91
+ match = re.search(r'\n(.*)$', block, re.DOTALL)
92
+ if match:
93
+ removed_texts.append(match.group(1).strip())
94
+
95
+ return "# Conversation History\n" + "\n".join(keep), removed_texts
96
+
97
+
98
+ # Global cache for vector stores per session (InMemoryVectorStore)
99
+ _memory_vector_stores: Dict[str, Any] = {}
100
+
101
+
102
+ def _get_memory_vector_store(session_id: str):
103
+ """Get or create InMemoryVectorStore for a session."""
104
+ if session_id not in _memory_vector_stores:
105
+ try:
106
+ from langchain_core.vectorstores import InMemoryVectorStore
107
+ from langchain_huggingface import HuggingFaceEmbeddings
108
+
109
+ embeddings = HuggingFaceEmbeddings(model_name="BAAI/bge-small-en-v1.5")
110
+ _memory_vector_stores[session_id] = InMemoryVectorStore(embeddings)
111
+ logger.debug(f"[Memory] Created vector store for session '{session_id}'")
112
+ except ImportError as e:
113
+ logger.warning(f"[Memory] Vector store not available: {e}")
114
+ return None
115
+ return _memory_vector_stores[session_id]
116
+
117
+
118
+ # =============================================================================
119
+ # AI PROVIDER REGISTRY - Single source of truth for provider configurations
120
+ # =============================================================================
121
+
122
+ @dataclass
123
+ class ProviderConfig:
124
+ """Configuration for an AI provider."""
125
+ name: str
126
+ model_class: Type
127
+ api_key_param: str # Parameter name for API key in model constructor
128
+ max_tokens_param: str # Parameter name for max tokens
129
+ detection_patterns: tuple # Patterns to detect this provider from model name
130
+ default_model: str # Default model when none specified
131
+ models_endpoint: str # API endpoint to fetch models
132
+ models_header_fn: Callable[[str], dict] # Function to create headers
133
+
134
+
135
+ @dataclass
136
+ class ThinkingConfig:
137
+ """Unified thinking/reasoning configuration across AI providers.
138
+
139
+ LangChain parameters per provider (Jan 2026):
140
+ - Claude: thinking={"type": "enabled", "budget_tokens": budget}, temp must be 1
141
+ - OpenAI o-series: reasoning_effort ('minimal', 'low', 'medium', 'high')
142
+ - Gemini 3+: thinking_level ('low', 'medium', 'high')
143
+ - Gemini 2.5: thinking_budget (int tokens)
144
+ - Groq: reasoning_format ('parsed', 'hidden')
145
+ """
146
+ enabled: bool = False
147
+ budget: int = 2048 # Token budget (Claude, Gemini 2.5)
148
+ effort: str = 'medium' # Effort level: 'minimal', 'low', 'medium', 'high' (OpenAI o-series)
149
+ level: str = 'medium' # Thinking level: 'low', 'medium', 'high' (Gemini 3+)
150
+ format: str = 'parsed' # Output format: 'parsed', 'hidden' (Groq)
151
+
152
+
153
+ def _openai_headers(api_key: str) -> dict:
154
+ return {'Authorization': f'Bearer {api_key}'}
155
+
156
+
157
+ def _anthropic_headers(api_key: str) -> dict:
158
+ return {'x-api-key': api_key, 'anthropic-version': '2023-06-01'}
159
+
160
+
161
+ def _gemini_headers(api_key: str) -> dict:
162
+ return {} # API key in URL for Gemini
163
+
164
+
165
+ def _openrouter_headers(api_key: str) -> dict:
166
+ return {
167
+ 'Authorization': f'Bearer {api_key}',
168
+ 'HTTP-Referer': 'http://localhost:3000',
169
+ 'X-Title': 'MachinaOS'
170
+ }
171
+
172
+
173
+ def _groq_headers(api_key: str) -> dict:
174
+ return {'Authorization': f'Bearer {api_key}'}
175
+
176
+
177
+ def _cerebras_headers(api_key: str) -> dict:
178
+ return {'Authorization': f'Bearer {api_key}'}
179
+
180
+
181
+ # Provider configurations
182
+ PROVIDER_CONFIGS: Dict[str, ProviderConfig] = {
183
+ 'openai': ProviderConfig(
184
+ name='openai',
185
+ model_class=ChatOpenAI,
186
+ api_key_param='openai_api_key',
187
+ max_tokens_param='max_tokens',
188
+ detection_patterns=('gpt', 'openai', 'o1'),
189
+ default_model='gpt-4o-mini',
190
+ models_endpoint='https://api.openai.com/v1/models',
191
+ models_header_fn=_openai_headers
192
+ ),
193
+ 'anthropic': ProviderConfig(
194
+ name='anthropic',
195
+ model_class=ChatAnthropic,
196
+ api_key_param='anthropic_api_key',
197
+ max_tokens_param='max_tokens',
198
+ detection_patterns=('claude', 'anthropic'),
199
+ default_model='claude-3-5-sonnet-20241022',
200
+ models_endpoint='https://api.anthropic.com/v1/models',
201
+ models_header_fn=_anthropic_headers
202
+ ),
203
+ 'gemini': ProviderConfig(
204
+ name='gemini',
205
+ model_class=ChatGoogleGenerativeAI,
206
+ api_key_param='google_api_key',
207
+ max_tokens_param='max_output_tokens',
208
+ detection_patterns=('gemini', 'google'),
209
+ default_model='gemini-1.5-pro',
210
+ models_endpoint='https://generativelanguage.googleapis.com/v1beta/models',
211
+ models_header_fn=_gemini_headers
212
+ ),
213
+ 'openrouter': ProviderConfig(
214
+ name='openrouter',
215
+ model_class=ChatOpenAI, # OpenRouter is OpenAI API compatible
216
+ api_key_param='api_key', # ChatOpenAI accepts 'api_key' as alias
217
+ max_tokens_param='max_tokens',
218
+ detection_patterns=('openrouter',),
219
+ default_model='openai/gpt-4o-mini',
220
+ models_endpoint='https://openrouter.ai/api/v1/models',
221
+ models_header_fn=_openrouter_headers
222
+ ),
223
+ 'groq': ProviderConfig(
224
+ name='groq',
225
+ model_class=ChatGroq,
226
+ api_key_param='api_key',
227
+ max_tokens_param='max_tokens',
228
+ detection_patterns=('groq',),
229
+ default_model='llama-3.3-70b-versatile',
230
+ models_endpoint='https://api.groq.com/openai/v1/models',
231
+ models_header_fn=_groq_headers
232
+ ),
233
+ }
234
+
235
+ # Add Cerebras only if available (requires Python <3.13)
236
+ if CEREBRAS_AVAILABLE:
237
+ PROVIDER_CONFIGS['cerebras'] = ProviderConfig(
238
+ name='cerebras',
239
+ model_class=ChatCerebras,
240
+ api_key_param='api_key',
241
+ max_tokens_param='max_tokens',
242
+ detection_patterns=('cerebras',),
243
+ default_model='llama-3.3-70b',
244
+ models_endpoint='https://api.cerebras.ai/v1/models',
245
+ models_header_fn=_cerebras_headers
246
+ )
247
+
248
+
249
+ def detect_provider_from_model(model: str) -> str:
250
+ """Detect AI provider from model name using registry patterns."""
251
+ model_lower = model.lower()
252
+ for provider_name, config in PROVIDER_CONFIGS.items():
253
+ if any(pattern in model_lower for pattern in config.detection_patterns):
254
+ return provider_name
255
+ return 'openai' # default
256
+
257
+
258
+ def is_model_valid_for_provider(model: str, provider: str) -> bool:
259
+ """Check if model name matches the provider's patterns."""
260
+ config = PROVIDER_CONFIGS.get(provider)
261
+ if not config:
262
+ return True
263
+ model_lower = model.lower()
264
+ return any(pattern in model_lower for pattern in config.detection_patterns)
265
+
266
+
267
+ def get_default_model(provider: str) -> str:
268
+ """Get default model for a provider."""
269
+ config = PROVIDER_CONFIGS.get(provider)
270
+ return config.default_model if config else 'gpt-4o-mini'
271
+
272
+
273
+ # =============================================================================
274
+ # MESSAGE FILTERING UTILITIES - Standardized for all providers
275
+ # =============================================================================
276
+
277
+ def is_valid_message_content(content: Any) -> bool:
278
+ """Check if message content is valid (non-empty) for API calls.
279
+
280
+ This is a standardized utility for validating message content before:
281
+ - Saving to conversation memory
282
+ - Including in API requests
283
+ - Building message history
284
+
285
+ Args:
286
+ content: The message content to validate (str, list, or other)
287
+
288
+ Returns:
289
+ True if content is valid and non-empty, False otherwise
290
+ """
291
+ if content is None:
292
+ return False
293
+
294
+ # Handle list content format (Gemini returns [{"type": "text", "text": "..."}])
295
+ if isinstance(content, list):
296
+ return any(
297
+ (isinstance(block, dict) and block.get('text', '').strip()) or
298
+ (isinstance(block, str) and block.strip())
299
+ for block in content
300
+ )
301
+
302
+ # Handle string content (most common)
303
+ if isinstance(content, str):
304
+ return bool(content.strip())
305
+
306
+ # Other truthy content types
307
+ return bool(content)
308
+
309
+
310
+ def filter_empty_messages(messages: Sequence[BaseMessage]) -> List[BaseMessage]:
311
+ """Filter out messages with empty content to prevent API errors.
312
+
313
+ This is a standardized utility that handles empty message filtering for all
314
+ AI providers (OpenAI, Anthropic/Claude, Google Gemini, and future providers).
315
+
316
+ Different providers have different sensitivities:
317
+ - Gemini: Emits "HumanMessage with empty content was removed" warning
318
+ - Claude/Anthropic: Throws errors for empty HumanMessage content
319
+ - OpenAI: Generally tolerant but empty messages waste tokens
320
+
321
+ This filter preserves:
322
+ - ToolMessage: Always kept (contains tool execution results)
323
+ - AIMessage with tool_calls: Kept even if content empty (tool calls are content)
324
+ - SystemMessage: Kept only if has non-empty content
325
+ - HumanMessage/others: Filtered if content is empty
326
+
327
+ Args:
328
+ messages: Sequence of LangChain BaseMessage objects
329
+
330
+ Returns:
331
+ Filtered list of messages with empty content removed
332
+ """
333
+ filtered = []
334
+
335
+ for m in messages:
336
+ # ToolMessage - always keep (contains tool execution results from LangGraph)
337
+ if isinstance(m, ToolMessage):
338
+ filtered.append(m)
339
+ continue
340
+
341
+ # AIMessage with tool_calls - keep even if content is empty
342
+ # (the tool calls themselves are the meaningful content)
343
+ if isinstance(m, AIMessage) and hasattr(m, 'tool_calls') and m.tool_calls:
344
+ filtered.append(m)
345
+ continue
346
+
347
+ # SystemMessage - keep only if has non-empty content
348
+ if isinstance(m, SystemMessage):
349
+ if hasattr(m, 'content') and m.content and str(m.content).strip():
350
+ filtered.append(m)
351
+ continue
352
+
353
+ # HumanMessage and other message types - filter out empty content
354
+ if hasattr(m, 'content'):
355
+ content = m.content
356
+
357
+ # Handle list content format (Gemini returns [{"type": "text", "text": "..."}])
358
+ if isinstance(content, list):
359
+ has_content = any(
360
+ (isinstance(block, dict) and block.get('text', '').strip()) or
361
+ (isinstance(block, str) and block.strip())
362
+ for block in content
363
+ )
364
+ if has_content:
365
+ filtered.append(m)
366
+
367
+ # Handle string content (most common)
368
+ elif isinstance(content, str) and content.strip():
369
+ filtered.append(m)
370
+
371
+ # Handle other non-empty content types (keep if truthy)
372
+ elif content:
373
+ filtered.append(m)
374
+ else:
375
+ # Message without content attr - keep it (might be special message type)
376
+ filtered.append(m)
377
+
378
+ return filtered
379
+
380
+
381
+ # =============================================================================
382
+ # LANGGRAPH STATE MACHINE DEFINITIONS
383
+ # =============================================================================
384
+
385
+ class AgentState(TypedDict):
386
+ """State for the LangGraph agent workflow.
387
+
388
+ Uses Annotated with operator.add to accumulate messages over steps.
389
+ This is the core pattern from LangGraph for stateful conversations.
390
+ """
391
+ messages: Annotated[Sequence[BaseMessage], operator.add]
392
+ # Tool outputs storage
393
+ tool_outputs: Dict[str, Any]
394
+ # Tool calling support
395
+ pending_tool_calls: List[Dict[str, Any]] # Tool calls from LLM to execute
396
+ # Agent metadata
397
+ iteration: int
398
+ max_iterations: int
399
+ should_continue: bool
400
+ # Thinking/reasoning content accumulated across iterations
401
+ thinking_content: Optional[str]
402
+
403
+
404
+ def extract_thinking_from_response(response) -> tuple:
405
+ """Extract text and thinking content from LLM response.
406
+
407
+ Handles multiple formats:
408
+ - LangChain content_blocks API (Claude, Gemini)
409
+ - OpenAI responses/v1 format (content list with reasoning blocks containing summary)
410
+ - Groq additional_kwargs.reasoning_content
411
+ - Raw string content
412
+
413
+ Returns:
414
+ Tuple of (text_content: str, thinking_content: Optional[str])
415
+ """
416
+ text_parts = []
417
+ thinking_parts = []
418
+
419
+ logger.debug(f"[extract_thinking] Starting extraction, response type: {type(response).__name__}")
420
+ logger.debug(f"[extract_thinking] has content_blocks: {hasattr(response, 'content_blocks')}, value: {getattr(response, 'content_blocks', None)}")
421
+ logger.debug(f"[extract_thinking] has content: {hasattr(response, 'content')}, type: {type(getattr(response, 'content', None))}")
422
+ logger.debug(f"[extract_thinking] has additional_kwargs: {hasattr(response, 'additional_kwargs')}, value: {getattr(response, 'additional_kwargs', None)}")
423
+ logger.debug(f"[extract_thinking] has response_metadata: {hasattr(response, 'response_metadata')}, keys: {list(getattr(response, 'response_metadata', {}).keys()) if hasattr(response, 'response_metadata') else None}")
424
+
425
+ # Use content_blocks API (LangChain 1.0+) for Claude/Gemini
426
+ if hasattr(response, 'content_blocks') and response.content_blocks:
427
+ for block in response.content_blocks:
428
+ if isinstance(block, dict):
429
+ block_type = block.get("type", "")
430
+ if block_type == "reasoning":
431
+ thinking_parts.append(block.get("reasoning", ""))
432
+ elif block_type == "thinking":
433
+ thinking_parts.append(block.get("thinking", ""))
434
+ elif block_type == "text":
435
+ text_parts.append(block.get("text", ""))
436
+
437
+ # Check additional_kwargs for reasoning_content (Groq, older OpenAI responses)
438
+ if not thinking_parts and hasattr(response, 'additional_kwargs'):
439
+ reasoning = response.additional_kwargs.get('reasoning_content')
440
+ if reasoning:
441
+ thinking_parts.append(reasoning)
442
+
443
+ # Check response_metadata for OpenAI o-series reasoning (responses/v1 format)
444
+ # The output array contains reasoning items with summaries
445
+ if not thinking_parts and hasattr(response, 'response_metadata'):
446
+ metadata = response.response_metadata
447
+ output = metadata.get('output', [])
448
+ if isinstance(output, list):
449
+ for item in output:
450
+ if isinstance(item, dict) and item.get('type') == 'reasoning':
451
+ summary = item.get('summary', [])
452
+ if isinstance(summary, list):
453
+ for s in summary:
454
+ if isinstance(s, dict):
455
+ # Handle both summary_text and text types
456
+ text = s.get('text', '')
457
+ if text:
458
+ thinking_parts.append(text)
459
+ elif isinstance(s, str):
460
+ thinking_parts.append(s)
461
+
462
+ # Check raw content for OpenAI responses/v1 format and other list formats
463
+ if hasattr(response, 'content'):
464
+ content = response.content
465
+ if isinstance(content, str):
466
+ if not text_parts:
467
+ text_parts.append(content)
468
+ elif isinstance(content, list):
469
+ for block in content:
470
+ if isinstance(block, dict):
471
+ block_type = block.get('type', '')
472
+ if block_type == 'text' or block_type == 'output_text':
473
+ # Handle both 'text' and 'output_text' (responses/v1 format)
474
+ if not text_parts: # Only add if not already extracted
475
+ text_parts.append(block.get('text', ''))
476
+ elif block_type == 'reasoning':
477
+ # OpenAI responses/v1 format: reasoning block with summary array
478
+ # Format: {"type": "reasoning", "summary": [{"type": "text", "text": "..."}, {"type": "summary_text", "text": "..."}]}
479
+ summary = block.get('summary', [])
480
+ if isinstance(summary, list):
481
+ for s in summary:
482
+ if isinstance(s, dict):
483
+ s_type = s.get('type', '')
484
+ if s_type in ('text', 'summary_text'):
485
+ thinking_parts.append(s.get('text', ''))
486
+ elif isinstance(s, str):
487
+ thinking_parts.append(s)
488
+ elif isinstance(summary, str):
489
+ thinking_parts.append(summary)
490
+ # Also check direct reasoning field
491
+ if block.get('reasoning'):
492
+ thinking_parts.append(block.get('reasoning', ''))
493
+ elif block_type == 'thinking':
494
+ thinking_parts.append(block.get('thinking', ''))
495
+ elif isinstance(block, str) and not text_parts:
496
+ text_parts.append(block)
497
+
498
+ text = '\n'.join(filter(None, text_parts))
499
+ thinking = '\n'.join(filter(None, thinking_parts)) if thinking_parts else None
500
+
501
+ logger.debug(f"[extract_thinking] Final text_parts: {text_parts}")
502
+ logger.debug(f"[extract_thinking] Final thinking_parts: {thinking_parts}")
503
+ logger.debug(f"[extract_thinking] Returning text={repr(text[:100] if text else None)}, thinking={repr(thinking[:100] if thinking else None)}")
504
+
505
+ return text, thinking
506
+
507
+
508
+ def create_agent_node(chat_model):
509
+ """Create the agent node function for LangGraph.
510
+
511
+ The agent node:
512
+ 1. Receives current state with messages
513
+ 2. Invokes the LLM
514
+ 3. Extracts thinking content if present
515
+ 4. Checks for tool calls in response
516
+ 5. Returns updated state with new AI message, thinking, and pending tool calls
517
+ """
518
+ def agent_node(state: AgentState) -> Dict[str, Any]:
519
+ """Process messages through the LLM and return response."""
520
+ messages = state["messages"]
521
+ iteration = state.get("iteration", 0)
522
+ max_iterations = state.get("max_iterations", 10)
523
+ existing_thinking = state.get("thinking_content") or ""
524
+
525
+ logger.debug(f"[LangGraph] Agent node invoked, iteration={iteration}, messages={len(messages)}")
526
+
527
+ # Filter out messages with empty content using standardized utility
528
+ # Prevents API errors/warnings from Gemini, Claude, and other providers
529
+ filtered_messages = filter_empty_messages(messages)
530
+
531
+ if len(filtered_messages) != len(messages):
532
+ logger.debug(f"[LangGraph] Filtered out {len(messages) - len(filtered_messages)} empty messages")
533
+
534
+ # Invoke the model
535
+ response = chat_model.invoke(filtered_messages)
536
+
537
+ logger.debug(f"[LangGraph] LLM response type: {type(response).__name__}")
538
+
539
+ # Extract thinking content from response
540
+ _, new_thinking = extract_thinking_from_response(response)
541
+
542
+ # Accumulate thinking across iterations (for multi-step tool usage)
543
+ accumulated_thinking = existing_thinking
544
+ if new_thinking:
545
+ if accumulated_thinking:
546
+ accumulated_thinking = f"{accumulated_thinking}\n\n--- Iteration {iteration + 1} ---\n{new_thinking}"
547
+ else:
548
+ accumulated_thinking = new_thinking
549
+ logger.debug(f"[LangGraph] Extracted thinking content ({len(new_thinking)} chars)")
550
+
551
+ # Check for Gemini-specific response attributes (safety ratings, block reason)
552
+ if hasattr(response, 'response_metadata'):
553
+ meta = response.response_metadata
554
+ if meta.get('finish_reason') == 'SAFETY':
555
+ logger.warning("[LangGraph] Gemini response blocked by safety filters")
556
+ if meta.get('block_reason'):
557
+ logger.warning(f"[LangGraph] Gemini block reason: {meta.get('block_reason')}")
558
+
559
+ # Check for tool calls in the response
560
+ pending_tool_calls = []
561
+ should_continue = False
562
+
563
+ if hasattr(response, 'tool_calls') and response.tool_calls:
564
+ # Model wants to use tools
565
+ pending_tool_calls = response.tool_calls
566
+ should_continue = True
567
+ logger.info(f"[LangGraph] Agent requesting {len(pending_tool_calls)} tool call(s)")
568
+ # Debug: log raw tool calls to diagnose empty args issue
569
+ for tc in pending_tool_calls:
570
+ logger.info(f"[LangGraph] Raw tool_call object: {tc}")
571
+ logger.info(f"[LangGraph] Tool call type: {type(tc)}, keys: {tc.keys() if hasattr(tc, 'keys') else 'N/A'}")
572
+
573
+ return {
574
+ "messages": [response], # Will be appended via operator.add
575
+ "tool_outputs": {},
576
+ "pending_tool_calls": pending_tool_calls,
577
+ "iteration": iteration + 1,
578
+ "max_iterations": max_iterations,
579
+ "should_continue": should_continue,
580
+ "thinking_content": accumulated_thinking or None
581
+ }
582
+
583
+ return agent_node
584
+
585
+
586
+ def create_tool_node(tool_executor: Callable):
587
+ """Create an async tool execution node for LangGraph.
588
+
589
+ The tool node:
590
+ 1. Receives pending tool calls from agent
591
+ 2. Executes each tool via the async tool_executor callback
592
+ 3. Returns ToolMessages with results for the agent
593
+
594
+ Note: This returns an async function for use with ainvoke().
595
+ LangGraph supports async node functions natively.
596
+ """
597
+ async def tool_node(state: AgentState) -> Dict[str, Any]:
598
+ """Execute pending tool calls and return results as ToolMessages."""
599
+ tool_messages = []
600
+
601
+ for tool_call in state.get("pending_tool_calls", []):
602
+ tool_name = tool_call.get("name", "unknown")
603
+ tool_args = tool_call.get("args", {})
604
+ tool_id = tool_call.get("id", "")
605
+
606
+ logger.info(f"[LangGraph] Executing tool: {tool_name} (args={tool_args})")
607
+
608
+ try:
609
+ # Directly await the async tool executor (proper async pattern)
610
+ result = await tool_executor(tool_name, tool_args)
611
+ logger.info(f"[LangGraph] Tool {tool_name} returned: {str(result)[:100]}")
612
+ except Exception as e:
613
+ logger.error(f"[LangGraph] Tool execution failed: {tool_name}", error=str(e))
614
+ result = {"error": str(e)}
615
+
616
+ # Create ToolMessage with result
617
+ tool_messages.append(ToolMessage(
618
+ content=json.dumps(result, default=str),
619
+ tool_call_id=tool_id,
620
+ name=tool_name
621
+ ))
622
+
623
+ logger.info(f"[LangGraph] Tool {tool_name} completed, result added to messages")
624
+
625
+ return {
626
+ "messages": tool_messages,
627
+ "pending_tool_calls": [], # Clear pending after execution
628
+ }
629
+
630
+ return tool_node
631
+
632
+
633
+ def should_continue(state: AgentState) -> str:
634
+ """Determine if the agent should continue or end.
635
+
636
+ This is the conditional edge function for LangGraph.
637
+ Returns "tools" to execute pending tool calls, or "end" to finish.
638
+ """
639
+ if state.get("should_continue", False):
640
+ if state.get("iteration", 0) < state.get("max_iterations", 10):
641
+ return "tools"
642
+ return "end"
643
+
644
+
645
+ def build_agent_graph(chat_model, tools: List = None, tool_executor: Callable = None):
646
+ """Build the LangGraph agent workflow with optional tool support.
647
+
648
+ Architecture (with tools):
649
+ START -> agent -> (conditional) -> tools -> agent -> ... -> END
650
+ |
651
+ +-> END (no tool calls)
652
+
653
+ Architecture (without tools):
654
+ START -> agent -> END
655
+
656
+ Args:
657
+ chat_model: The LangChain chat model
658
+ tools: Optional list of LangChain tools to bind to the model
659
+ tool_executor: Optional async callback to execute tools
660
+ """
661
+ # Create the graph with our state schema
662
+ graph = StateGraph(AgentState)
663
+
664
+ # Bind tools to model if provided
665
+ model_with_tools = chat_model
666
+ if tools:
667
+ model_with_tools = chat_model.bind_tools(tools)
668
+ logger.debug(f"[LangGraph] Bound {len(tools)} tools to model")
669
+
670
+ # Add the agent node
671
+ agent_fn = create_agent_node(model_with_tools)
672
+ graph.add_node("agent", agent_fn)
673
+
674
+ # Set entry point
675
+ graph.set_entry_point("agent")
676
+
677
+ if tools and tool_executor:
678
+ # Add tool execution node
679
+ tool_fn = create_tool_node(tool_executor)
680
+ graph.add_node("tools", tool_fn)
681
+
682
+ # Conditional routing: agent -> tools or end
683
+ graph.add_conditional_edges(
684
+ "agent",
685
+ should_continue,
686
+ {
687
+ "tools": "tools",
688
+ "end": END
689
+ }
690
+ )
691
+
692
+ # Tools always route back to agent
693
+ graph.add_edge("tools", "agent")
694
+
695
+ logger.debug("[LangGraph] Built graph with tool execution loop")
696
+ else:
697
+ # Simple graph without tools
698
+ graph.add_conditional_edges(
699
+ "agent",
700
+ should_continue,
701
+ {
702
+ "tools": "agent", # Fallback loop (shouldn't happen without tools)
703
+ "end": END
704
+ }
705
+ )
706
+
707
+ # Compile the graph
708
+ return graph.compile()
709
+
710
+
711
+ class AIService:
712
+ """AI model service for LangChain operations."""
713
+
714
+ def __init__(self, auth_service: AuthService, database, cache, settings: Settings):
715
+ self.auth = auth_service
716
+ self.database = database
717
+ self.cache = cache
718
+ self.settings = settings
719
+
720
+ def detect_provider(self, model: str) -> str:
721
+ """Detect AI provider from model name."""
722
+ return detect_provider_from_model(model)
723
+
724
+ def _extract_text_content(self, content, ai_response=None) -> str:
725
+ """Extract text content from various response formats.
726
+
727
+ Handles:
728
+ - String content (OpenAI, Anthropic)
729
+ - List of content blocks (Gemini 3+ models)
730
+ - Empty/None content with error details from metadata
731
+
732
+ Args:
733
+ content: The raw content from response (str, list, or None)
734
+ ai_response: The full AIMessage for metadata inspection
735
+
736
+ Returns:
737
+ Extracted text string
738
+
739
+ Raises:
740
+ ValueError: If content is empty with details about why
741
+ """
742
+ # Handle list content (Gemini format: [{"type": "text", "text": "..."}])
743
+ if isinstance(content, list):
744
+ text_parts = []
745
+ for block in content:
746
+ if isinstance(block, dict):
747
+ if block.get('type') == 'text' and block.get('text'):
748
+ text_parts.append(block['text'])
749
+ elif 'text' in block:
750
+ text_parts.append(str(block['text']))
751
+ elif isinstance(block, str):
752
+ text_parts.append(block)
753
+ extracted = '\n'.join(text_parts)
754
+ if extracted.strip():
755
+ return extracted
756
+ # List was present but no text extracted
757
+ logger.warning(f"[LangGraph] Content was list but no text extracted: {content}")
758
+
759
+ # Handle string content
760
+ if isinstance(content, str) and content.strip():
761
+ return content
762
+
763
+ # Content is empty - try to get error details from metadata
764
+ error_details = []
765
+ if ai_response and hasattr(ai_response, 'response_metadata'):
766
+ meta = ai_response.response_metadata
767
+ finish_reason = meta.get('finish_reason', '')
768
+
769
+ if finish_reason == 'SAFETY':
770
+ error_details.append("Content blocked by safety filters")
771
+ # Try to get specific blocked categories
772
+ safety_ratings = meta.get('safety_ratings', [])
773
+ blocked = [r.get('category') for r in safety_ratings if r.get('blocked')]
774
+ if blocked:
775
+ error_details.append(f"Blocked categories: {', '.join(blocked)}")
776
+
777
+ elif finish_reason == 'MAX_TOKENS':
778
+ # Check if reasoning consumed all tokens
779
+ token_details = meta.get('output_token_details', {})
780
+ reasoning_tokens = token_details.get('reasoning', 0)
781
+ output_tokens = meta.get('usage_metadata', {}).get('candidates_token_count', 0)
782
+ if reasoning_tokens > 0 and output_tokens == 0:
783
+ error_details.append(f"Model used all tokens for reasoning ({reasoning_tokens} tokens). Try increasing max_tokens or simplifying the prompt.")
784
+ else:
785
+ error_details.append("Response truncated due to max_tokens limit")
786
+
787
+ elif finish_reason == 'MALFORMED_FUNCTION_CALL':
788
+ error_details.append("Model returned malformed function call. Tool schema may be incompatible.")
789
+
790
+ if meta.get('block_reason'):
791
+ error_details.append(f"Block reason: {meta.get('block_reason')}")
792
+
793
+ if error_details:
794
+ raise ValueError(f"AI returned empty response. {'; '.join(error_details)}")
795
+
796
+ # Generic empty response
797
+ logger.warning(f"[LangGraph] Empty response with no error details. Content type: {type(content)}, value: {content}")
798
+ raise ValueError("AI generated empty response. Try rephrasing your prompt or using a different model.")
799
+
800
+ def _is_reasoning_model(self, model: str) -> bool:
801
+ """Check if model supports reasoning (OpenAI o-series).
802
+
803
+ O-series models: o1, o1-mini, o1-preview, o3, o3-mini, o4-mini, etc.
804
+ NOT gpt-4o (which contains 'o' but is not a reasoning model).
805
+ """
806
+ model_lower = model.lower()
807
+ # Check for o-series pattern: starts with 'o' followed by digit
808
+ # e.g., o1, o1-mini, o3, o3-mini, o4-mini
809
+ return bool(re.match(r'^o[134](-|$)', model_lower))
810
+
811
+ def create_model(self, provider: str, api_key: str, model: str,
812
+ temperature: float, max_tokens: int,
813
+ thinking: Optional[ThinkingConfig] = None):
814
+ """Create LangChain model instance using provider registry.
815
+
816
+ Args:
817
+ provider: AI provider name (openai, anthropic, gemini, groq, openrouter)
818
+ api_key: Provider API key
819
+ model: Model name/ID
820
+ temperature: Sampling temperature
821
+ max_tokens: Maximum response tokens
822
+ thinking: Optional thinking/reasoning configuration
823
+
824
+ Returns:
825
+ Configured LangChain chat model instance
826
+ """
827
+ config = PROVIDER_CONFIGS.get(provider)
828
+ if not config:
829
+ raise ValueError(f"Unsupported provider: {provider}")
830
+
831
+ # Build kwargs dynamically from registry config
832
+ kwargs = {
833
+ config.api_key_param: api_key,
834
+ 'model': model,
835
+ 'temperature': temperature,
836
+ config.max_tokens_param: max_tokens
837
+ }
838
+
839
+ # OpenRouter uses OpenAI-compatible API with custom base_url
840
+ if provider == 'openrouter':
841
+ kwargs['base_url'] = 'https://openrouter.ai/api/v1'
842
+ kwargs['default_headers'] = {
843
+ 'HTTP-Referer': 'http://localhost:3000',
844
+ 'X-Title': 'MachinaOS'
845
+ }
846
+
847
+ # OpenAI o-series reasoning models ALWAYS require temperature=1
848
+ # This applies regardless of whether thinking mode is enabled
849
+ if provider == 'openai' and self._is_reasoning_model(model):
850
+ if kwargs.get('temperature', 1) != 1:
851
+ logger.info(f"[AI] OpenAI o-series model '{model}': forcing temperature to 1 (was {kwargs.get('temperature')})")
852
+ kwargs['temperature'] = 1
853
+
854
+ # Apply thinking/reasoning configuration per provider (per LangChain docs Jan 2026)
855
+ if thinking and thinking.enabled:
856
+ if provider == 'anthropic':
857
+ # Claude extended thinking: thinking={"type": "enabled", "budget_tokens": N}
858
+ # Requires temperature=1, budget min 1024 tokens
859
+ # IMPORTANT: max_tokens must be greater than budget_tokens
860
+ budget = max(1024, thinking.budget)
861
+ # Ensure max_tokens > budget_tokens (add buffer for response)
862
+ if max_tokens <= budget:
863
+ # Set max_tokens to budget + reasonable response space (at least 1024 more)
864
+ kwargs[config.max_tokens_param] = budget + max(1024, max_tokens)
865
+ logger.info(f"[AI] Claude thinking: adjusted max_tokens from {max_tokens} to {kwargs[config.max_tokens_param]} (budget={budget})")
866
+ kwargs['thinking'] = {"type": "enabled", "budget_tokens": budget}
867
+ kwargs['temperature'] = 1 # Required for Claude thinking mode
868
+ elif provider == 'openai' and self._is_reasoning_model(model):
869
+ # OpenAI o-series: Use reasoning_effort parameter
870
+ # Note: reasoning.summary requires organization verification on OpenAI
871
+ # So we just use reasoning_effort for now
872
+ kwargs['reasoning_effort'] = thinking.effort
873
+ # O-series models only support temperature=1
874
+ kwargs['temperature'] = 1
875
+ logger.info(f"[AI] OpenAI o-series: reasoning_effort={thinking.effort}, temperature=1")
876
+ elif provider == 'gemini':
877
+ # Gemini thinking support varies by model:
878
+ # - Gemini 2.5 Flash/Pro: Use thinking_budget (int tokens, 0=off, -1=dynamic)
879
+ # - Gemini 2.0 Flash Thinking: Built-in thinking, use thinking_budget
880
+ # - Gemini 3 models: Limited/experimental thinking support
881
+ model_lower = model.lower()
882
+
883
+ # Gemini 2.5 models support thinking_budget
884
+ if 'gemini-2.5' in model_lower or '2.5' in model_lower:
885
+ kwargs['thinking_budget'] = thinking.budget
886
+ kwargs['include_thoughts'] = True
887
+ logger.info(f"[AI] Gemini 2.5 thinking: budget={thinking.budget}")
888
+ # Gemini 2.0 Flash Thinking model
889
+ elif 'gemini-2.0-flash-thinking' in model_lower or 'flash-thinking' in model_lower:
890
+ kwargs['thinking_budget'] = thinking.budget
891
+ kwargs['include_thoughts'] = True
892
+ logger.info(f"[AI] Gemini Flash Thinking: budget={thinking.budget}")
893
+ # Gemini 3 preview models - thinking support is experimental/limited
894
+ # Only 'low' and 'high' levels may be supported, not 'medium'
895
+ elif 'gemini-3' in model_lower:
896
+ # For Gemini 3, try thinking_budget instead of thinking_level
897
+ # as level support is inconsistent across preview models
898
+ kwargs['thinking_budget'] = thinking.budget
899
+ kwargs['include_thoughts'] = True
900
+ logger.info(f"[AI] Gemini 3 thinking: using budget={thinking.budget} (level support varies)")
901
+ # Other Gemini 2.x models - try thinking_budget
902
+ elif 'gemini-2' in model_lower:
903
+ kwargs['thinking_budget'] = thinking.budget
904
+ kwargs['include_thoughts'] = True
905
+ logger.info(f"[AI] Gemini 2.x thinking: budget={thinking.budget}")
906
+ else:
907
+ # For other/older Gemini models, thinking may not be supported
908
+ logger.warning(f"[AI] Gemini model '{model}' may not support thinking mode")
909
+ elif provider == 'groq':
910
+ # Groq: reasoning_format ('parsed' or 'hidden')
911
+ # 'parsed' includes reasoning in additional_kwargs, 'hidden' suppresses it
912
+ format_val = thinking.format if thinking.format in ('parsed', 'hidden') else 'parsed'
913
+ kwargs['reasoning_format'] = format_val
914
+ elif provider == 'cerebras':
915
+ # Cerebras: No official LangChain thinking support yet
916
+ # Passing through as model_kwargs if supported
917
+ kwargs['thinking_budget'] = thinking.budget
918
+
919
+ return config.model_class(**kwargs)
920
+
921
+ async def fetch_models(self, provider: str, api_key: str) -> List[str]:
922
+ """Fetch available models from provider API."""
923
+ async with httpx.AsyncClient(timeout=self.settings.ai_timeout) as client:
924
+ if provider == 'openai':
925
+ response = await client.get(
926
+ 'https://api.openai.com/v1/models',
927
+ headers={'Authorization': f'Bearer {api_key}'}
928
+ )
929
+ response.raise_for_status()
930
+ data = response.json()
931
+
932
+ # Filter for chat models including o-series reasoning models
933
+ models = []
934
+ for model in data.get('data', []):
935
+ model_id = model['id'].lower()
936
+ # Include GPT models and o-series reasoning models (o1, o3, o4)
937
+ is_gpt = 'gpt' in model_id
938
+ is_o_series = any(f'o{n}' in model_id for n in ['1', '3', '4'])
939
+ is_excluded = 'instruct' in model_id or 'embedding' in model_id or 'realtime' in model_id
940
+ if (is_gpt or is_o_series) and not is_excluded:
941
+ models.append(model['id'])
942
+
943
+ # Sort by priority - o-series reasoning models at top
944
+ def get_priority(model_name: str) -> int:
945
+ m = model_name.lower()
946
+ # O-series reasoning models first
947
+ if 'o4-mini' in m: return 1
948
+ if 'o4' in m: return 2
949
+ if 'o3-mini' in m: return 3
950
+ if 'o3' in m: return 4
951
+ if 'o1-mini' in m: return 5
952
+ if 'o1' in m: return 6
953
+ # Then GPT models
954
+ if 'gpt-4o-mini' in m: return 10
955
+ if 'gpt-4o' in m: return 11
956
+ if 'gpt-4-turbo' in m: return 12
957
+ if 'gpt-4' in m: return 13
958
+ if 'gpt-3.5' in m: return 20
959
+ return 99
960
+
961
+ return sorted(models, key=get_priority)
962
+
963
+ elif provider == 'anthropic':
964
+ response = await client.get(
965
+ 'https://api.anthropic.com/v1/models',
966
+ headers={
967
+ 'x-api-key': api_key,
968
+ 'anthropic-version': '2023-06-01'
969
+ }
970
+ )
971
+ response.raise_for_status()
972
+ data = response.json()
973
+ return [model['id'] for model in data.get('data', [])
974
+ if model.get('type') == 'model']
975
+
976
+ elif provider == 'gemini':
977
+ response = await client.get(
978
+ f'https://generativelanguage.googleapis.com/v1beta/models?key={api_key}'
979
+ )
980
+ response.raise_for_status()
981
+ data = response.json()
982
+
983
+ models = []
984
+ for model in data.get('models', []):
985
+ name = model.get('name', '')
986
+ if ('gemini' in name and
987
+ 'generateContent' in model.get('supportedGenerationMethods', [])):
988
+ models.append(name.replace('models/', ''))
989
+
990
+ return sorted(models)
991
+
992
+ elif provider == 'openrouter':
993
+ response = await client.get(
994
+ 'https://openrouter.ai/api/v1/models',
995
+ headers={'Authorization': f'Bearer {api_key}'}
996
+ )
997
+ response.raise_for_status()
998
+ data = response.json()
999
+
1000
+ free_models = []
1001
+ paid_models = []
1002
+ for model in data.get('data', []):
1003
+ model_id = model.get('id', '')
1004
+ arch = model.get('architecture', {})
1005
+ modality = arch.get('modality', '')
1006
+ if 'text' in modality and model_id:
1007
+ pricing = model.get('pricing', {})
1008
+ prompt_price = float(pricing.get('prompt', '0') or '0')
1009
+ completion_price = float(pricing.get('completion', '0') or '0')
1010
+ is_free = prompt_price == 0 and completion_price == 0
1011
+ # Add [FREE] tag to free models
1012
+ display_name = f"[FREE] {model_id}" if is_free else model_id
1013
+ if is_free:
1014
+ free_models.append(display_name)
1015
+ else:
1016
+ paid_models.append(display_name)
1017
+
1018
+ # Return free models first, then paid models (both sorted)
1019
+ return sorted(free_models) + sorted(paid_models)
1020
+
1021
+ elif provider == 'groq':
1022
+ response = await client.get(
1023
+ 'https://api.groq.com/openai/v1/models',
1024
+ headers={'Authorization': f'Bearer {api_key}'}
1025
+ )
1026
+ response.raise_for_status()
1027
+ data = response.json()
1028
+
1029
+ models = []
1030
+ for model in data.get('data', []):
1031
+ model_id = model.get('id', '')
1032
+ # Include all models from Groq API
1033
+ if model_id:
1034
+ models.append(model_id)
1035
+
1036
+ return sorted(models)
1037
+
1038
+ elif provider == 'cerebras':
1039
+ response = await client.get(
1040
+ 'https://api.cerebras.ai/v1/models',
1041
+ headers={'Authorization': f'Bearer {api_key}'}
1042
+ )
1043
+ response.raise_for_status()
1044
+ data = response.json()
1045
+
1046
+ models = []
1047
+ for model in data.get('data', []):
1048
+ model_id = model.get('id', '')
1049
+ # Include all models from Cerebras API
1050
+ if model_id:
1051
+ models.append(model_id)
1052
+
1053
+ return sorted(models)
1054
+
1055
+ else:
1056
+ raise ValueError(f"Unsupported provider: {provider}")
1057
+
1058
+ async def execute_chat(self, node_id: str, node_type: str, parameters: Dict[str, Any]) -> Dict[str, Any]:
1059
+ """Execute AI chat model."""
1060
+ start_time = time.time()
1061
+
1062
+ try:
1063
+ # Flatten options collection from frontend
1064
+ options = parameters.get('options', {})
1065
+ flattened = {**parameters, **options}
1066
+
1067
+ # Extract parameters with camelCase/snake_case support for LangChain
1068
+ api_key = flattened.get('api_key') or flattened.get('apiKey')
1069
+ model = flattened.get('model', 'gpt-3.5-turbo')
1070
+ # Strip [FREE] prefix if present (added by OpenRouter model list for display)
1071
+ if model.startswith('[FREE] '):
1072
+ model = model[7:]
1073
+ prompt = flattened.get('prompt', 'Hello')
1074
+
1075
+ # System prompt/message - support multiple naming conventions
1076
+ system_prompt = (flattened.get('system_prompt') or
1077
+ flattened.get('systemMessage') or
1078
+ flattened.get('systemPrompt') or '')
1079
+
1080
+ # Max tokens - support camelCase from frontend
1081
+ max_tokens = int(flattened.get('max_tokens') or
1082
+ flattened.get('maxTokens') or 1000)
1083
+
1084
+ temperature = float(flattened.get('temperature', 0.7))
1085
+
1086
+ if not api_key:
1087
+ raise ValueError("API key is required")
1088
+
1089
+ # Validate prompt is not empty (prevents wasted API calls for all providers)
1090
+ if not is_valid_message_content(prompt):
1091
+ raise ValueError("Prompt cannot be empty")
1092
+
1093
+ # Determine provider from node_type (more reliable than model name detection)
1094
+ # OpenRouter models have format like "openai/gpt-4o" which would incorrectly detect as openai
1095
+ if node_type == 'openrouterChatModel':
1096
+ provider = 'openrouter'
1097
+ elif node_type == 'groqChatModel':
1098
+ provider = 'groq'
1099
+ elif node_type == 'cerebrasChatModel':
1100
+ provider = 'cerebras'
1101
+ else:
1102
+ provider = self.detect_provider(model)
1103
+
1104
+ # Build thinking config from parameters
1105
+ thinking_config = None
1106
+ if flattened.get('thinkingEnabled'):
1107
+ thinking_config = ThinkingConfig(
1108
+ enabled=True,
1109
+ budget=int(flattened.get('thinkingBudget', 2048)),
1110
+ effort=flattened.get('reasoningEffort', 'medium'),
1111
+ level=flattened.get('thinkingLevel', 'medium'),
1112
+ format=flattened.get('reasoningFormat', 'parsed'),
1113
+ )
1114
+
1115
+ chat_model = self.create_model(provider, api_key, model, temperature, max_tokens, thinking_config)
1116
+
1117
+ # Prepare messages
1118
+ messages = []
1119
+ if system_prompt and is_valid_message_content(system_prompt):
1120
+ messages.append(SystemMessage(content=system_prompt))
1121
+ messages.append(HumanMessage(content=prompt))
1122
+
1123
+ # Filter messages using standardized utility (handles all providers consistently)
1124
+ filtered_messages = filter_empty_messages(messages)
1125
+
1126
+ # Execute
1127
+ response = chat_model.invoke(filtered_messages)
1128
+
1129
+ # Debug: Log response structure for o-series reasoning models
1130
+ if thinking_config and thinking_config.enabled:
1131
+ logger.info(f"[AI Debug] Response type: {type(response).__name__}")
1132
+ logger.info(f"[AI Debug] Response content type: {type(response.content)}")
1133
+ logger.info(f"[AI Debug] Response content: {response.content[:500] if isinstance(response.content, str) else response.content}")
1134
+ if hasattr(response, 'content_blocks'):
1135
+ logger.info(f"[AI Debug] content_blocks: {response.content_blocks}")
1136
+ if hasattr(response, 'additional_kwargs'):
1137
+ logger.info(f"[AI Debug] additional_kwargs: {response.additional_kwargs}")
1138
+ if hasattr(response, 'response_metadata'):
1139
+ logger.info(f"[AI Debug] response_metadata: {response.response_metadata}")
1140
+
1141
+ # Extract text and thinking content from response
1142
+ text_content, thinking_content = extract_thinking_from_response(response)
1143
+
1144
+ # Debug: Log extraction results
1145
+ logger.info(f"[AI Debug] Extracted text_content: {repr(text_content[:200] if text_content else None)}")
1146
+ logger.info(f"[AI Debug] Extracted thinking_content: {repr(thinking_content[:200] if thinking_content else None)}")
1147
+
1148
+ # Use extracted text if available, fall back to raw content
1149
+ response_text = text_content if text_content else response.content
1150
+
1151
+ logger.info(f"[AI Debug] Final response_text: {repr(response_text[:200] if response_text else None)}")
1152
+
1153
+ result = {
1154
+ "response": response_text,
1155
+ "thinking": thinking_content,
1156
+ "thinking_enabled": thinking_config.enabled if thinking_config else False,
1157
+ "model": model,
1158
+ "provider": provider,
1159
+ "finish_reason": "stop",
1160
+ "timestamp": datetime.now().isoformat(),
1161
+ "input": {
1162
+ "prompt": prompt,
1163
+ "system_prompt": system_prompt,
1164
+ }
1165
+ }
1166
+
1167
+ log_execution_time(logger, "ai_chat", start_time, time.time())
1168
+ log_api_call(logger, provider, model, "chat", True)
1169
+
1170
+ final_result = {
1171
+ "success": True,
1172
+ "node_id": node_id,
1173
+ "node_type": node_type,
1174
+ "result": result,
1175
+ "execution_time": time.time() - start_time
1176
+ }
1177
+ logger.info(f"[AI Debug] Returning final_result: success={final_result['success']}, result.response={repr(result.get('response', 'MISSING')[:100] if result.get('response') else 'None')}")
1178
+ return final_result
1179
+
1180
+ except Exception as e:
1181
+ logger.error("AI execution failed", node_id=node_id, error=str(e))
1182
+ log_api_call(logger, provider if 'provider' in locals() else 'unknown',
1183
+ model if 'model' in locals() else 'unknown', "chat", False, error=str(e))
1184
+
1185
+ return {
1186
+ "success": False,
1187
+ "node_id": node_id,
1188
+ "node_type": node_type,
1189
+ "error": str(e),
1190
+ "execution_time": time.time() - start_time,
1191
+ "timestamp": datetime.now().isoformat()
1192
+ }
1193
+
1194
+ async def execute_agent(self, node_id: str, parameters: Dict[str, Any],
1195
+ memory_data: Optional[Dict[str, Any]] = None,
1196
+ tool_data: Optional[List[Dict[str, Any]]] = None,
1197
+ broadcaster = None,
1198
+ workflow_id: Optional[str] = None) -> Dict[str, Any]:
1199
+ """Execute AI Agent using LangGraph state machine.
1200
+
1201
+ This method uses LangGraph for structured agent execution with:
1202
+ - State management via TypedDict
1203
+ - Tool calling via bind_tools and tool execution node
1204
+ - Message accumulation via operator.add pattern
1205
+ - Real-time status broadcasts for UI animations
1206
+
1207
+ Args:
1208
+ node_id: The node identifier
1209
+ parameters: Node parameters including prompt, model, etc.
1210
+ memory_data: Optional memory data from connected simpleMemory node
1211
+ containing session_id, window_size for conversation history
1212
+ tool_data: Optional list of tool configurations from connected tool nodes
1213
+ broadcaster: Optional StatusBroadcaster for real-time UI updates
1214
+ workflow_id: Optional workflow ID for scoped status broadcasts
1215
+ """
1216
+ start_time = time.time()
1217
+ provider = 'unknown'
1218
+ model = 'unknown'
1219
+
1220
+ # EARLY LOG: Entry point for debugging
1221
+ logger.info(f"[AIAgent] execute_agent called: node_id={node_id}, workflow_id={workflow_id}, tool_data_count={len(tool_data) if tool_data else 0}")
1222
+ if tool_data:
1223
+ for i, td in enumerate(tool_data):
1224
+ logger.info(f"[AIAgent] Tool {i}: type={td.get('node_type')}, node_id={td.get('node_id')}")
1225
+
1226
+ # Helper to broadcast status updates with workflow_id for proper scoping
1227
+ async def broadcast_status(phase: str, details: Dict[str, Any] = None):
1228
+ if broadcaster:
1229
+ await broadcaster.update_node_status(node_id, "executing", {
1230
+ "phase": phase,
1231
+ "agent_type": "langgraph",
1232
+ **(details or {})
1233
+ }, workflow_id=workflow_id)
1234
+
1235
+ try:
1236
+ # Extract top-level parameters (always visible in UI)
1237
+ prompt = parameters.get('prompt', 'Hello')
1238
+ system_message = parameters.get('systemMessage', 'You are a helpful assistant')
1239
+
1240
+ # Flatten options collection from frontend
1241
+ options = parameters.get('options', {})
1242
+ flattened = {**parameters, **options}
1243
+
1244
+ # Extract parameters with camelCase/snake_case support
1245
+ api_key = flattened.get('api_key') or flattened.get('apiKey')
1246
+ provider = parameters.get('provider', 'openai')
1247
+ model = parameters.get('model', '')
1248
+ temperature = float(flattened.get('temperature', 0.7))
1249
+ max_tokens = int(flattened.get('max_tokens') or flattened.get('maxTokens') or 1000)
1250
+
1251
+ logger.info(f"[LangGraph] Agent: {provider}/{model}, tools={len(tool_data) if tool_data else 0}")
1252
+
1253
+ # If no model specified or model doesn't match provider, use default from registry
1254
+ if not model or not is_model_valid_for_provider(model, provider):
1255
+ old_model = model
1256
+ model = get_default_model(provider)
1257
+ if old_model:
1258
+ logger.warning(f"Model '{old_model}' invalid for provider '{provider}', using default: {model}")
1259
+ else:
1260
+ logger.info(f"No model specified, using default: {model}")
1261
+
1262
+ if not api_key:
1263
+ raise ValueError("API key is required for AI Agent")
1264
+
1265
+ # Build thinking config from parameters
1266
+ thinking_config = None
1267
+ if flattened.get('thinkingEnabled'):
1268
+ thinking_config = ThinkingConfig(
1269
+ enabled=True,
1270
+ budget=int(flattened.get('thinkingBudget', 2048)),
1271
+ effort=flattened.get('reasoningEffort', 'medium'),
1272
+ level=flattened.get('thinkingLevel', 'medium'),
1273
+ format=flattened.get('reasoningFormat', 'parsed'),
1274
+ )
1275
+ logger.info(f"[LangGraph] Thinking enabled: budget={thinking_config.budget}, effort={thinking_config.effort}")
1276
+
1277
+ # Broadcast: Initializing model
1278
+ await broadcast_status("initializing", {
1279
+ "message": f"Initializing {provider} model...",
1280
+ "provider": provider,
1281
+ "model": model
1282
+ })
1283
+
1284
+ # Create LLM using the provider from node configuration
1285
+ logger.debug(f"[LangGraph] Creating {provider} model: {model}")
1286
+ chat_model = self.create_model(provider, api_key, model, temperature, max_tokens, thinking_config)
1287
+
1288
+ # Build initial messages for state
1289
+ initial_messages: List[BaseMessage] = []
1290
+ if system_message:
1291
+ initial_messages.append(SystemMessage(content=system_message))
1292
+
1293
+ # Add memory history from connected simpleMemory node (markdown-based)
1294
+ session_id = None
1295
+ history_count = 0
1296
+ if memory_data and memory_data.get('session_id'):
1297
+ session_id = memory_data['session_id']
1298
+ memory_content = memory_data.get('memory_content', '')
1299
+
1300
+ # Broadcast: Loading memory
1301
+ await broadcast_status("loading_memory", {
1302
+ "message": f"Loading conversation history...",
1303
+ "session_id": session_id,
1304
+ "has_memory": True
1305
+ })
1306
+
1307
+ # Parse short-term memory from markdown
1308
+ history_messages = _parse_memory_markdown(memory_content)
1309
+ history_count = len(history_messages)
1310
+
1311
+ # If long-term memory enabled, retrieve relevant context
1312
+ if memory_data.get('long_term_enabled'):
1313
+ store = _get_memory_vector_store(session_id)
1314
+ if store:
1315
+ try:
1316
+ k = memory_data.get('retrieval_count', 3)
1317
+ docs = store.similarity_search(prompt, k=k)
1318
+ if docs:
1319
+ context = "\n---\n".join(d.page_content for d in docs)
1320
+ initial_messages.append(SystemMessage(content=f"Relevant past context:\n{context}"))
1321
+ logger.info(f"[LangGraph Memory] Retrieved {len(docs)} relevant memories from long-term store")
1322
+ except Exception as e:
1323
+ logger.debug(f"[LangGraph Memory] Long-term retrieval skipped: {e}")
1324
+
1325
+ # Add parsed history messages
1326
+ initial_messages.extend(history_messages)
1327
+
1328
+ logger.info(f"[LangGraph Memory] Loaded {history_count} messages from markdown")
1329
+
1330
+ # Broadcast: Memory loaded
1331
+ await broadcast_status("memory_loaded", {
1332
+ "message": f"Loaded {history_count} messages from memory",
1333
+ "session_id": session_id,
1334
+ "history_count": history_count
1335
+ })
1336
+
1337
+ # Add current user prompt
1338
+ initial_messages.append(HumanMessage(content=prompt))
1339
+
1340
+ # Build tools if provided
1341
+ tools = []
1342
+ tool_configs = {}
1343
+
1344
+ if tool_data:
1345
+ await broadcast_status("building_tools", {
1346
+ "message": f"Building {len(tool_data)} tool(s)...",
1347
+ "tool_count": len(tool_data)
1348
+ })
1349
+
1350
+ for tool_info in tool_data:
1351
+ tool, config = await self._build_tool_from_node(tool_info)
1352
+ if tool:
1353
+ tools.append(tool)
1354
+ tool_configs[tool.name] = config
1355
+ logger.info(f"[LangGraph] Registered tool: name={tool.name}, node_id={config.get('node_id')}")
1356
+
1357
+ logger.debug(f"[LangGraph] Built {len(tools)} tools")
1358
+
1359
+ # Create tool executor callback
1360
+ async def tool_executor(tool_name: str, tool_args: Dict) -> Any:
1361
+ """Execute a tool by name."""
1362
+ from services.handlers.tools import execute_tool
1363
+
1364
+ config = tool_configs.get(tool_name, {})
1365
+ tool_node_id = config.get('node_id')
1366
+
1367
+ # Log tool execution details for debugging glow animation
1368
+ logger.info(f"[LangGraph] Executing tool: {tool_name} (args={tool_args})")
1369
+ logger.info(f"[LangGraph] Available tool configs: {list(tool_configs.keys())}")
1370
+ logger.info(f"[LangGraph] Tool node_id={tool_node_id}, workflow_id={workflow_id}, broadcaster={'yes' if broadcaster else 'no'}")
1371
+
1372
+ # Broadcast executing status to the AI Agent node
1373
+ await broadcast_status("executing_tool", {
1374
+ "message": f"Executing tool: {tool_name}",
1375
+ "tool_name": tool_name,
1376
+ "tool_args": tool_args
1377
+ })
1378
+
1379
+ # Also broadcast executing status directly to the tool node so it glows
1380
+ if tool_node_id and broadcaster:
1381
+ await broadcaster.update_node_status(
1382
+ tool_node_id,
1383
+ "executing",
1384
+ {"message": f"Executing {tool_name}"},
1385
+ workflow_id=workflow_id
1386
+ )
1387
+
1388
+ # Include workflow_id in config so tool handlers can broadcast with proper scoping
1389
+ config['workflow_id'] = workflow_id
1390
+
1391
+ try:
1392
+ result = await execute_tool(tool_name, tool_args, config)
1393
+
1394
+ # Broadcast completion to AI Agent node
1395
+ await broadcast_status("tool_completed", {
1396
+ "message": f"Tool completed: {tool_name}",
1397
+ "tool_name": tool_name,
1398
+ "result_preview": str(result)[:100]
1399
+ })
1400
+
1401
+ # Broadcast success status to the tool node
1402
+ if tool_node_id and broadcaster:
1403
+ await broadcaster.update_node_status(
1404
+ tool_node_id,
1405
+ "success",
1406
+ {"message": f"{tool_name} completed", "result": result},
1407
+ workflow_id=workflow_id
1408
+ )
1409
+
1410
+ return result
1411
+
1412
+ except Exception as e:
1413
+ error_msg = str(e)
1414
+ logger.error(f"[LangGraph] Tool execution failed: {tool_name}", error=error_msg)
1415
+
1416
+ # Broadcast error status to the tool node so UI shows failure
1417
+ if tool_node_id and broadcaster:
1418
+ await broadcaster.update_node_status(
1419
+ tool_node_id,
1420
+ "error",
1421
+ {"message": f"{tool_name} failed", "error": error_msg},
1422
+ workflow_id=workflow_id
1423
+ )
1424
+
1425
+ # Re-raise to let LangGraph handle the error
1426
+ raise
1427
+
1428
+ # Broadcast: Building graph
1429
+ await broadcast_status("building_graph", {
1430
+ "message": "Building LangGraph agent...",
1431
+ "message_count": len(initial_messages),
1432
+ "has_memory": bool(session_id),
1433
+ "history_count": history_count,
1434
+ "tool_count": len(tools)
1435
+ })
1436
+
1437
+ # Build and execute LangGraph agent
1438
+ logger.debug(f"[LangGraph] Building agent graph with {len(initial_messages)} messages")
1439
+ agent_graph = build_agent_graph(
1440
+ chat_model,
1441
+ tools=tools if tools else None,
1442
+ tool_executor=tool_executor if tools else None
1443
+ )
1444
+
1445
+ # Create initial state with thinking_content for reasoning models
1446
+ initial_state: AgentState = {
1447
+ "messages": initial_messages,
1448
+ "tool_outputs": {},
1449
+ "pending_tool_calls": [],
1450
+ "iteration": 0,
1451
+ "max_iterations": 10,
1452
+ "should_continue": False,
1453
+ "thinking_content": None
1454
+ }
1455
+
1456
+ # Broadcast: Executing graph
1457
+ await broadcast_status("invoking_llm", {
1458
+ "message": f"Invoking {provider} LLM...",
1459
+ "provider": provider,
1460
+ "model": model,
1461
+ "iteration": 1,
1462
+ "has_memory": bool(session_id),
1463
+ "history_count": history_count
1464
+ })
1465
+
1466
+ # Execute the graph using ainvoke for proper async support
1467
+ # This allows async tool nodes and WebSocket broadcasts to work correctly
1468
+ final_state = await agent_graph.ainvoke(initial_state)
1469
+
1470
+ # Extract the AI response (last message in the accumulated messages)
1471
+ all_messages = final_state["messages"]
1472
+ ai_response = all_messages[-1] if all_messages else None
1473
+
1474
+ if not ai_response or not hasattr(ai_response, 'content'):
1475
+ raise ValueError("No response generated from agent")
1476
+
1477
+ # Handle different content formats (Gemini can return list of content blocks)
1478
+ raw_content = ai_response.content
1479
+ response_content = self._extract_text_content(raw_content, ai_response)
1480
+ iterations = final_state.get("iteration", 1)
1481
+
1482
+ # Get accumulated thinking content from state
1483
+ thinking_content = final_state.get("thinking_content")
1484
+
1485
+ logger.info(f"[LangGraph] Agent completed in {iterations} iteration(s), thinking={'yes' if thinking_content else 'no'}")
1486
+
1487
+ # Save to memory if connected (markdown-based with optional vector DB)
1488
+ # Only save non-empty messages using standardized validation
1489
+ if memory_data and memory_data.get('node_id') and is_valid_message_content(prompt) and is_valid_message_content(response_content):
1490
+ # Broadcast: Saving to memory
1491
+ await broadcast_status("saving_memory", {
1492
+ "message": "Saving to conversation memory...",
1493
+ "session_id": session_id,
1494
+ "has_memory": True,
1495
+ "history_count": history_count
1496
+ })
1497
+
1498
+ # Update markdown content
1499
+ updated_content = memory_data.get('memory_content', '# Conversation History\n\n*No messages yet.*\n')
1500
+ updated_content = _append_to_memory_markdown(updated_content, 'human', prompt)
1501
+ updated_content = _append_to_memory_markdown(updated_content, 'ai', response_content)
1502
+
1503
+ # Trim to window size, archive removed to vector DB
1504
+ window_size = memory_data.get('window_size', 10)
1505
+ updated_content, removed_texts = _trim_markdown_window(updated_content, window_size)
1506
+
1507
+ # Store removed messages in long-term vector DB
1508
+ if removed_texts and memory_data.get('long_term_enabled'):
1509
+ store = _get_memory_vector_store(session_id)
1510
+ if store:
1511
+ try:
1512
+ store.add_texts(removed_texts)
1513
+ logger.info(f"[LangGraph Memory] Archived {len(removed_texts)} messages to long-term store")
1514
+ except Exception as e:
1515
+ logger.warning(f"[LangGraph Memory] Failed to archive to vector store: {e}")
1516
+
1517
+ # Save updated markdown to node parameters
1518
+ memory_node_id = memory_data['node_id']
1519
+ current_params = await self.database.get_node_parameters(memory_node_id) or {}
1520
+ current_params['memoryContent'] = updated_content
1521
+ await self.database.save_node_parameters(memory_node_id, current_params)
1522
+ logger.info(f"[LangGraph Memory] Saved markdown to memory node '{memory_node_id}'")
1523
+
1524
+ result = {
1525
+ "response": response_content,
1526
+ "thinking": thinking_content,
1527
+ "thinking_enabled": thinking_config.enabled if thinking_config else False,
1528
+ "model": model,
1529
+ "provider": provider,
1530
+ "agent_type": "langgraph",
1531
+ "iterations": iterations,
1532
+ "finish_reason": "stop",
1533
+ "timestamp": datetime.now().isoformat(),
1534
+ "input": {
1535
+ "prompt": prompt,
1536
+ "system_message": system_message,
1537
+ }
1538
+ }
1539
+
1540
+ # Add memory info if used
1541
+ if session_id:
1542
+ result["memory"] = {
1543
+ "session_id": session_id,
1544
+ "history_loaded": history_count
1545
+ }
1546
+
1547
+ log_execution_time(logger, "ai_agent_langgraph", start_time, time.time())
1548
+ log_api_call(logger, provider, model, "agent", True)
1549
+
1550
+ return {
1551
+ "success": True,
1552
+ "node_id": node_id,
1553
+ "node_type": "aiAgent",
1554
+ "result": result,
1555
+ "execution_time": time.time() - start_time
1556
+ }
1557
+
1558
+ except Exception as e:
1559
+ logger.error("[LangGraph] AI agent execution failed", node_id=node_id, error=str(e))
1560
+ log_api_call(logger, provider, model, "agent", False, error=str(e))
1561
+
1562
+ return {
1563
+ "success": False,
1564
+ "node_id": node_id,
1565
+ "node_type": "aiAgent",
1566
+ "error": str(e),
1567
+ "execution_time": time.time() - start_time,
1568
+ "timestamp": datetime.now().isoformat()
1569
+ }
1570
+
1571
+ async def execute_chat_agent(self, node_id: str, parameters: Dict[str, Any],
1572
+ memory_data: Optional[Dict[str, Any]] = None,
1573
+ skill_data: Optional[List[Dict[str, Any]]] = None,
1574
+ tool_data: Optional[List[Dict[str, Any]]] = None,
1575
+ broadcaster=None,
1576
+ workflow_id: Optional[str] = None) -> Dict[str, Any]:
1577
+ """Execute Chat Agent - conversational AI with memory, skills, and tool calling.
1578
+
1579
+ Chat Agent supports:
1580
+ - Memory (input-memory): Markdown-based conversation history (same as AI Agent)
1581
+ - Skills (input-skill): Provide context/instructions via SKILL.md
1582
+ - Tools (input-tools): Tool nodes (httpRequest, etc.) for LangGraph tool calling
1583
+
1584
+ Args:
1585
+ node_id: The node identifier
1586
+ parameters: Node parameters including prompt, model, etc.
1587
+ memory_data: Optional memory data from connected SimpleMemory node (markdown-based)
1588
+ skill_data: Optional skill configurations from connected skill nodes
1589
+ tool_data: Optional tool configurations from connected tool nodes (httpRequest, etc.)
1590
+ broadcaster: Optional StatusBroadcaster for real-time UI updates
1591
+ workflow_id: Optional workflow ID for scoped status broadcasts
1592
+ """
1593
+ start_time = time.time()
1594
+ provider = 'unknown'
1595
+ model = 'unknown'
1596
+
1597
+ logger.info(f"[ChatAgent] execute_chat_agent called: node_id={node_id}, workflow_id={workflow_id}, skill_count={len(skill_data) if skill_data else 0}, tool_count={len(tool_data) if tool_data else 0}")
1598
+
1599
+ async def broadcast_status(phase: str, details: Dict[str, Any] = None):
1600
+ if broadcaster:
1601
+ await broadcaster.update_node_status(node_id, "executing", {
1602
+ "phase": phase,
1603
+ "agent_type": "chat_with_skills" if skill_data else "chat",
1604
+ **(details or {})
1605
+ }, workflow_id=workflow_id)
1606
+
1607
+ try:
1608
+ # Extract parameters
1609
+ prompt = parameters.get('prompt', 'Hello')
1610
+ system_message = parameters.get('systemMessage', 'You are a helpful assistant')
1611
+
1612
+ # Load skills and enhance system message with SKILL.md context
1613
+ # Skills only provide instructions/context - actual tools come from direct tool nodes
1614
+ if skill_data:
1615
+ from services.skill_loader import get_skill_loader
1616
+
1617
+ skill_loader = get_skill_loader()
1618
+ skill_loader.scan_skills()
1619
+
1620
+ # Extract skill names from connected skill nodes
1621
+ skill_names = []
1622
+ for skill_info in skill_data:
1623
+ skill_name = skill_info.get('skill_name') or skill_info.get('node_type', '').replace('Skill', '-skill').lower()
1624
+ # Convert node type to skill name (e.g., 'whatsappSkill' -> 'whatsapp-skill')
1625
+ if skill_name.endswith('skill') and not '-' in skill_name:
1626
+ skill_name = skill_name[:-5] + '-skill' # whatsappskill -> whatsapp-skill
1627
+ skill_names.append(skill_name)
1628
+ logger.debug(f"[ChatAgent] Skill detected: {skill_name}")
1629
+
1630
+ # Add skill SKILL.md content to system message
1631
+ skill_prompt = skill_loader.get_registry_prompt(skill_names)
1632
+ if skill_prompt:
1633
+ system_message = f"{system_message}\n\n{skill_prompt}"
1634
+ logger.info(f"[ChatAgent] Enhanced system message with {len(skill_names)} skill contexts")
1635
+
1636
+ # Build tools from tool_data using same method as AI Agent
1637
+ # This supports ALL tool types: calculatorTool, currentTimeTool, webSearchTool, androidTool, httpRequest
1638
+ all_tools = []
1639
+ tool_node_configs = {} # Map tool name to node config (same as AI Agent's tool_configs)
1640
+ if tool_data:
1641
+ await broadcast_status("building_tools", {
1642
+ "message": f"Building {len(tool_data)} tool(s)...",
1643
+ "tool_count": len(tool_data)
1644
+ })
1645
+
1646
+ for tool_info in tool_data:
1647
+ # Use AI Agent's _build_tool_from_node for all tool types
1648
+ tool, config = await self._build_tool_from_node(tool_info)
1649
+ if tool:
1650
+ all_tools.append(tool)
1651
+ tool_node_configs[tool.name] = config
1652
+ logger.info(f"[ChatAgent] Built tool: {tool.name} (type={config.get('node_type')}, node_id={config.get('node_id')})")
1653
+
1654
+ logger.info(f"[ChatAgent] Built {len(all_tools)} tools from tool_data")
1655
+
1656
+ logger.info(f"[ChatAgent] Total tools available: {len(all_tools)}")
1657
+ # Debug: log all tool schemas to verify they're correct
1658
+ for t in all_tools:
1659
+ schema = t.get_input_schema().model_json_schema()
1660
+ logger.debug(f"[ChatAgent] Tool '{t.name}' schema: {schema}")
1661
+
1662
+ # Flatten options collection from frontend
1663
+ options = parameters.get('options', {})
1664
+ flattened = {**parameters, **options}
1665
+
1666
+ api_key = flattened.get('api_key') or flattened.get('apiKey')
1667
+ provider = parameters.get('provider', 'openai')
1668
+ model = parameters.get('model', '')
1669
+ temperature = float(flattened.get('temperature', 0.7))
1670
+ max_tokens = int(flattened.get('max_tokens') or flattened.get('maxTokens') or 1000)
1671
+
1672
+ logger.info(f"[ChatAgent] Provider: {provider}, Model: {model}")
1673
+
1674
+ # Validate model for provider
1675
+ if not model or not is_model_valid_for_provider(model, provider):
1676
+ old_model = model
1677
+ model = get_default_model(provider)
1678
+ if old_model:
1679
+ logger.warning(f"Model '{old_model}' invalid for provider '{provider}', using default: {model}")
1680
+ else:
1681
+ logger.info(f"No model specified, using default: {model}")
1682
+
1683
+ if not api_key:
1684
+ raise ValueError("API key is required for Zeenie")
1685
+
1686
+ # Build thinking config from parameters
1687
+ thinking_config = None
1688
+ if flattened.get('thinkingEnabled'):
1689
+ thinking_config = ThinkingConfig(
1690
+ enabled=True,
1691
+ budget=int(flattened.get('thinkingBudget', 2048)),
1692
+ effort=flattened.get('reasoningEffort', 'medium'),
1693
+ level=flattened.get('thinkingLevel', 'medium'),
1694
+ format=flattened.get('reasoningFormat', 'parsed'),
1695
+ )
1696
+
1697
+ # Broadcast: Initializing
1698
+ await broadcast_status("initializing", {
1699
+ "message": f"Initializing {provider} model...",
1700
+ "provider": provider,
1701
+ "model": model
1702
+ })
1703
+
1704
+ # Create chat model
1705
+ chat_model = self.create_model(provider, api_key, model, temperature, max_tokens, thinking_config)
1706
+
1707
+ # Build messages
1708
+ messages: List[BaseMessage] = []
1709
+ if system_message:
1710
+ messages.append(SystemMessage(content=system_message))
1711
+
1712
+ # Load memory history if connected (markdown-based like AI Agent)
1713
+ session_id = None
1714
+ history_count = 0
1715
+ memory_content = None
1716
+ if memory_data and memory_data.get('node_id'):
1717
+ session_id = memory_data.get('session_id', 'default')
1718
+ memory_content = memory_data.get('memory_content', '# Conversation History\n\n*No messages yet.*\n')
1719
+
1720
+ await broadcast_status("loading_memory", {
1721
+ "message": "Loading conversation history...",
1722
+ "session_id": session_id,
1723
+ "has_memory": True
1724
+ })
1725
+
1726
+ # Parse short-term memory from markdown
1727
+ history_messages = _parse_memory_markdown(memory_content)
1728
+ history_count = len(history_messages)
1729
+
1730
+ # If long-term memory enabled, retrieve relevant context
1731
+ if memory_data.get('long_term_enabled'):
1732
+ store = _get_memory_vector_store(session_id)
1733
+ if store:
1734
+ try:
1735
+ k = memory_data.get('retrieval_count', 3)
1736
+ docs = store.similarity_search(prompt, k=k)
1737
+ if docs:
1738
+ context = "\n---\n".join(d.page_content for d in docs)
1739
+ messages.append(SystemMessage(content=f"Relevant past context:\n{context}"))
1740
+ logger.info(f"[ChatAgent Memory] Retrieved {len(docs)} relevant memories from long-term store")
1741
+ except Exception as e:
1742
+ logger.debug(f"[ChatAgent Memory] Long-term retrieval skipped: {e}")
1743
+
1744
+ # Add parsed history messages
1745
+ messages.extend(history_messages)
1746
+
1747
+ logger.info(f"[ChatAgent Memory] Loaded {history_count} messages from markdown")
1748
+
1749
+ await broadcast_status("memory_loaded", {
1750
+ "message": f"Loaded {history_count} messages from memory",
1751
+ "session_id": session_id,
1752
+ "history_count": history_count
1753
+ })
1754
+
1755
+ # Add current prompt
1756
+ messages.append(HumanMessage(content=prompt))
1757
+
1758
+ # Broadcast: Invoking LLM
1759
+ await broadcast_status("invoking_llm", {
1760
+ "message": "Generating response...",
1761
+ "has_memory": session_id is not None,
1762
+ "history_count": history_count,
1763
+ "skill_count": len(skill_data) if skill_data else 0
1764
+ })
1765
+
1766
+ # Execute with or without tools
1767
+ thinking_content = None
1768
+ iterations = 1
1769
+
1770
+ if all_tools:
1771
+ # Use LangGraph for tool execution (like AI Agent)
1772
+ logger.info(f"[ChatAgent] Using LangGraph with {len(all_tools)} tools")
1773
+
1774
+ # Create tool executor callback - same pattern as AI Agent
1775
+ # Uses handlers/tools.py execute_tool() for actual execution
1776
+ async def chat_tool_executor(tool_name: str, tool_args: Dict) -> Any:
1777
+ """Execute a tool by name using handlers/tools.py (same as AI Agent)."""
1778
+ from services.handlers.tools import execute_tool
1779
+
1780
+ logger.info(f"[ChatAgent] Executing tool: {tool_name}, args={tool_args}")
1781
+
1782
+ # Get tool node config (contains node_id, node_type, parameters)
1783
+ config = tool_node_configs.get(tool_name, {})
1784
+ tool_node_id = config.get('node_id')
1785
+ logger.info(f"[ChatAgent] Tool config: node_id={tool_node_id}, node_type={config.get('node_type')}, workflow_id={workflow_id}")
1786
+
1787
+ # Broadcast executing status to tool node for glow effect
1788
+ if tool_node_id and broadcaster:
1789
+ logger.info(f"[ChatAgent] Broadcasting 'executing' status to node {tool_node_id}")
1790
+ await broadcaster.update_node_status(
1791
+ tool_node_id,
1792
+ "executing",
1793
+ {"message": f"Executing {tool_name}"},
1794
+ workflow_id=workflow_id
1795
+ )
1796
+
1797
+ # Include workflow_id in config so tool handlers can broadcast with proper scoping
1798
+ # This is needed for Android toolkit to broadcast status to connected service nodes
1799
+ config['workflow_id'] = workflow_id
1800
+
1801
+ try:
1802
+ # Execute via handlers/tools.py - same pattern as AI Agent
1803
+ result = await execute_tool(tool_name, tool_args, config)
1804
+ logger.info(f"[ChatAgent] Tool executed successfully: {tool_name}")
1805
+
1806
+ # Broadcast success to tool node
1807
+ if tool_node_id and broadcaster:
1808
+ await broadcaster.update_node_status(
1809
+ tool_node_id,
1810
+ "success",
1811
+ {"message": f"{tool_name} completed", "result": result},
1812
+ workflow_id=workflow_id
1813
+ )
1814
+ return result
1815
+
1816
+ except Exception as e:
1817
+ logger.error(f"[ChatAgent] Tool execution failed: {tool_name}", error=str(e))
1818
+ # Broadcast error to tool node
1819
+ if tool_node_id and broadcaster:
1820
+ await broadcaster.update_node_status(
1821
+ tool_node_id,
1822
+ "error",
1823
+ {"message": f"{tool_name} failed", "error": str(e)},
1824
+ workflow_id=workflow_id
1825
+ )
1826
+ return {"error": str(e)}
1827
+
1828
+ # Build LangGraph agent with all tools
1829
+ agent_graph = build_agent_graph(
1830
+ chat_model,
1831
+ tools=all_tools,
1832
+ tool_executor=chat_tool_executor
1833
+ )
1834
+
1835
+ # Create initial state
1836
+ initial_state: AgentState = {
1837
+ "messages": messages,
1838
+ "tool_outputs": {},
1839
+ "pending_tool_calls": [],
1840
+ "iteration": 0,
1841
+ "max_iterations": 10,
1842
+ "should_continue": False,
1843
+ "thinking_content": None
1844
+ }
1845
+
1846
+ # Execute the graph
1847
+ final_state = await agent_graph.ainvoke(initial_state)
1848
+
1849
+ # Extract response
1850
+ all_messages = final_state["messages"]
1851
+ ai_response = all_messages[-1] if all_messages else None
1852
+
1853
+ if not ai_response or not hasattr(ai_response, 'content'):
1854
+ raise ValueError("No response generated from agent")
1855
+
1856
+ raw_content = ai_response.content
1857
+ response_content = self._extract_text_content(raw_content, ai_response)
1858
+ iterations = final_state.get("iteration", 1)
1859
+ thinking_content = final_state.get("thinking_content")
1860
+ else:
1861
+ # Simple invoke without tools
1862
+ response = await chat_model.ainvoke(messages)
1863
+
1864
+ # Extract response content
1865
+ raw_content = response.content
1866
+ response_content = self._extract_text_content(raw_content, response)
1867
+
1868
+ # Extract thinking content if available
1869
+ _, thinking_content = extract_thinking_from_response(response)
1870
+
1871
+ logger.info(f"[ChatAgent] Response generated, thinking={'yes' if thinking_content else 'no'}, iterations={iterations}")
1872
+
1873
+ # Save to memory if connected (markdown-based like AI Agent)
1874
+ if memory_data and memory_data.get('node_id') and is_valid_message_content(prompt) and is_valid_message_content(response_content):
1875
+ await broadcast_status("saving_memory", {
1876
+ "message": "Saving to conversation memory...",
1877
+ "session_id": session_id,
1878
+ "has_memory": True
1879
+ })
1880
+
1881
+ # Update markdown content
1882
+ updated_content = memory_content or '# Conversation History\n\n*No messages yet.*\n'
1883
+ updated_content = _append_to_memory_markdown(updated_content, 'human', prompt)
1884
+ updated_content = _append_to_memory_markdown(updated_content, 'ai', response_content)
1885
+
1886
+ # Trim to window size, archive removed to vector DB
1887
+ window_size = memory_data.get('window_size', 10)
1888
+ updated_content, removed_texts = _trim_markdown_window(updated_content, window_size)
1889
+
1890
+ # Store removed messages in long-term vector DB
1891
+ if removed_texts and memory_data.get('long_term_enabled'):
1892
+ store = _get_memory_vector_store(session_id)
1893
+ if store:
1894
+ try:
1895
+ store.add_texts(removed_texts)
1896
+ logger.info(f"[ChatAgent Memory] Archived {len(removed_texts)} messages to long-term store")
1897
+ except Exception as e:
1898
+ logger.warning(f"[ChatAgent Memory] Failed to archive to vector store: {e}")
1899
+
1900
+ # Save updated markdown to node parameters
1901
+ memory_node_id = memory_data['node_id']
1902
+ current_params = await self.database.get_node_parameters(memory_node_id) or {}
1903
+ current_params['memoryContent'] = updated_content
1904
+ await self.database.save_node_parameters(memory_node_id, current_params)
1905
+ logger.info(f"[ChatAgent Memory] Saved markdown to memory node '{memory_node_id}'")
1906
+
1907
+ # Determine agent type based on configuration
1908
+ agent_type = "chat"
1909
+ if skill_data and all_tools:
1910
+ agent_type = "chat_with_skills_and_tools"
1911
+ elif skill_data:
1912
+ agent_type = "chat_with_skills"
1913
+ elif all_tools:
1914
+ agent_type = "chat_with_tools"
1915
+
1916
+ result = {
1917
+ "response": response_content,
1918
+ "thinking": thinking_content,
1919
+ "thinking_enabled": thinking_config.enabled if thinking_config else False,
1920
+ "model": model,
1921
+ "provider": provider,
1922
+ "agent_type": agent_type,
1923
+ "iterations": iterations,
1924
+ "finish_reason": "stop",
1925
+ "timestamp": datetime.now().isoformat(),
1926
+ "input": {
1927
+ "prompt": prompt,
1928
+ "system_message": system_message,
1929
+ }
1930
+ }
1931
+
1932
+ if session_id:
1933
+ result["memory"] = {
1934
+ "session_id": session_id,
1935
+ "history_loaded": history_count
1936
+ }
1937
+
1938
+ if skill_data:
1939
+ result["skills"] = {
1940
+ "connected": [s.get('skill_name', s.get('node_type', '')) for s in skill_data],
1941
+ "count": len(skill_data)
1942
+ }
1943
+
1944
+ if all_tools:
1945
+ result["tools"] = {
1946
+ "connected": [t.name for t in all_tools],
1947
+ "count": len(all_tools)
1948
+ }
1949
+
1950
+ log_execution_time(logger, "chat_agent", start_time, time.time())
1951
+ log_api_call(logger, provider, model, "chat_agent", True)
1952
+
1953
+ return {
1954
+ "success": True,
1955
+ "node_id": node_id,
1956
+ "node_type": "chatAgent",
1957
+ "result": result,
1958
+ "execution_time": time.time() - start_time
1959
+ }
1960
+
1961
+ except Exception as e:
1962
+ logger.error("[ChatAgent] Execution failed", node_id=node_id, error=str(e))
1963
+ log_api_call(logger, provider, model, "chat_agent", False, error=str(e))
1964
+
1965
+ return {
1966
+ "success": False,
1967
+ "node_id": node_id,
1968
+ "node_type": "chatAgent",
1969
+ "error": str(e),
1970
+ "execution_time": time.time() - start_time,
1971
+ "timestamp": datetime.now().isoformat()
1972
+ }
1973
+
1974
+ async def _build_tool_from_node(self, tool_info: Dict[str, Any]) -> tuple:
1975
+ """Convert a node configuration into a LangChain StructuredTool.
1976
+
1977
+ Uses database-stored schema as source of truth if available, otherwise
1978
+ falls back to dynamic schema generation.
1979
+
1980
+ Args:
1981
+ tool_info: Dict containing node_id, node_type, parameters, label, connected_services (for androidTool)
1982
+
1983
+ Returns:
1984
+ Tuple of (StructuredTool, config_dict) or (None, None) on failure
1985
+ """
1986
+ # Default tool names matching frontend toolNodes.ts definitions
1987
+ DEFAULT_TOOL_NAMES = {
1988
+ 'calculatorTool': 'calculator',
1989
+ 'currentTimeTool': 'get_current_time',
1990
+ 'webSearchTool': 'web_search',
1991
+ 'pythonExecutor': 'python_code',
1992
+ 'javascriptExecutor': 'javascript_code',
1993
+ 'androidTool': 'android_device',
1994
+ 'whatsappSend': 'whatsapp_send',
1995
+ 'whatsappDb': 'whatsapp_db',
1996
+ 'addLocations': 'geocode',
1997
+ 'showNearbyPlaces': 'nearby_places',
1998
+ }
1999
+ DEFAULT_TOOL_DESCRIPTIONS = {
2000
+ 'calculatorTool': 'Perform mathematical calculations. Operations: add, subtract, multiply, divide, power, sqrt, mod, abs',
2001
+ 'currentTimeTool': 'Get the current date and time. Optionally specify timezone.',
2002
+ 'webSearchTool': 'Search the web for information. Returns relevant search results.',
2003
+ 'pythonExecutor': 'Execute Python code for calculations, data processing, and automation. Available: math, json, datetime, Counter, defaultdict. Set output variable with result.',
2004
+ 'javascriptExecutor': 'Execute JavaScript code for calculations, data processing, and JSON manipulation. Set output variable with result.',
2005
+ 'androidTool': 'Control Android device. Available services are determined by connected nodes.',
2006
+ 'whatsappSend': 'Send WhatsApp messages to contacts or groups. Supports text, media, location, and contact messages.',
2007
+ 'whatsappDb': 'Query WhatsApp database - list contacts, search groups, get contact/group info, retrieve chat history.',
2008
+ 'addLocations': 'Geocode addresses to coordinates or reverse geocode coordinates to addresses using Google Maps.',
2009
+ 'showNearbyPlaces': 'Search for nearby places (restaurants, hospitals, banks, etc.) using Google Maps Places API.',
2010
+ }
2011
+
2012
+ try:
2013
+ node_type = tool_info.get('node_type', '')
2014
+ node_params = tool_info.get('parameters', {})
2015
+ node_label = tool_info.get('label', node_type)
2016
+ node_id = tool_info.get('node_id', '')
2017
+ connected_services = tool_info.get('connected_services', [])
2018
+
2019
+ # Check database for stored schema (source of truth)
2020
+ db_schema = await self.database.get_tool_schema(node_id) if node_id else None
2021
+
2022
+ if db_schema:
2023
+ # Use database schema as source of truth
2024
+ logger.debug(f"[LangGraph] Using DB schema for tool node {node_id}")
2025
+ tool_name = db_schema.get('tool_name', DEFAULT_TOOL_NAMES.get(node_type, f"tool_{node_label}"))
2026
+ tool_description = db_schema.get('tool_description', DEFAULT_TOOL_DESCRIPTIONS.get(node_type, f"Execute {node_label}"))
2027
+ # Use stored connected_services if available (for toolkit nodes)
2028
+ if db_schema.get('connected_services'):
2029
+ connected_services = db_schema['connected_services']
2030
+ else:
2031
+ # Fall back to dynamic generation from node params
2032
+ tool_name = (
2033
+ node_params.get('toolName') or
2034
+ DEFAULT_TOOL_NAMES.get(node_type) or
2035
+ f"tool_{node_label}".replace(' ', '_').replace('-', '_').lower()
2036
+ )
2037
+ tool_description = (
2038
+ node_params.get('toolDescription') or
2039
+ DEFAULT_TOOL_DESCRIPTIONS.get(node_type) or
2040
+ f"Execute {node_label} node"
2041
+ )
2042
+
2043
+ # For androidTool, enhance description with connected services
2044
+ if node_type == 'androidTool' and connected_services:
2045
+ service_names = [s.get('label') or s.get('service_id', 'unknown') for s in connected_services]
2046
+ tool_description = f"{tool_description} Connected: {', '.join(service_names)}"
2047
+
2048
+ # Clean tool name (LangChain requires alphanumeric + underscores)
2049
+ import re
2050
+ tool_name = re.sub(r'[^a-zA-Z0-9_]', '_', tool_name)
2051
+
2052
+ # Build schema based on node type - pass connected_services for androidTool
2053
+ # If DB has schema_config, use it to build custom schema, otherwise use dynamic
2054
+ schema_params = dict(node_params)
2055
+ if connected_services:
2056
+ schema_params['connected_services'] = connected_services
2057
+ if db_schema and db_schema.get('schema_config'):
2058
+ schema_params['db_schema_config'] = db_schema['schema_config']
2059
+ schema = self._get_tool_schema(node_type, schema_params)
2060
+
2061
+ # Create StructuredTool - the func is a placeholder, actual execution via tool_executor
2062
+ def placeholder_func(**kwargs):
2063
+ return kwargs
2064
+
2065
+ tool = StructuredTool.from_function(
2066
+ name=tool_name,
2067
+ description=tool_description,
2068
+ func=placeholder_func,
2069
+ args_schema=schema
2070
+ )
2071
+
2072
+ # Build config dict - include connected_services for toolkit nodes
2073
+ config = {
2074
+ 'node_type': node_type,
2075
+ 'node_id': node_id,
2076
+ 'parameters': node_params,
2077
+ 'label': node_label,
2078
+ 'connected_services': connected_services # Pass through for execution
2079
+ }
2080
+
2081
+ logger.debug(f"[LangGraph] Built tool '{tool_name}' with node_id={node_id}")
2082
+ return tool, config
2083
+
2084
+ except Exception as e:
2085
+ logger.error(f"[LangGraph] Failed to build tool from node: {e}")
2086
+ return None, None
2087
+
2088
+ def _get_tool_schema(self, node_type: str, params: Dict[str, Any]) -> Type[BaseModel]:
2089
+ """Get Pydantic schema for tool based on node type.
2090
+
2091
+ Uses db_schema_config from database if available (source of truth),
2092
+ otherwise falls back to built-in schema definitions.
2093
+
2094
+ Args:
2095
+ node_type: The node type (e.g., 'calculatorTool', 'httpRequest')
2096
+ params: Node parameters, may include db_schema_config from database
2097
+
2098
+ Returns:
2099
+ Pydantic BaseModel class for the tool's arguments
2100
+ """
2101
+ # Check if we have a database-stored schema config (source of truth)
2102
+ db_schema_config = params.get('db_schema_config')
2103
+ if db_schema_config:
2104
+ return self._build_schema_from_config(db_schema_config)
2105
+
2106
+ # Calculator tool schema
2107
+ if node_type == 'calculatorTool':
2108
+ class CalculatorSchema(BaseModel):
2109
+ """Schema for calculator tool arguments."""
2110
+ operation: str = Field(
2111
+ description="Math operation: add, subtract, multiply, divide, power, sqrt, mod, abs"
2112
+ )
2113
+ a: float = Field(description="First number")
2114
+ b: float = Field(default=0, description="Second number (not needed for sqrt, abs)")
2115
+
2116
+ return CalculatorSchema
2117
+
2118
+ # HTTP Request tool schema
2119
+ if node_type in ('httpRequest', 'httpRequestTool'):
2120
+ class HttpRequestSchema(BaseModel):
2121
+ """Schema for HTTP request tool arguments."""
2122
+ url: str = Field(description="URL path or full URL to request")
2123
+ method: str = Field(default="GET", description="HTTP method: GET, POST, PUT, DELETE")
2124
+ body: Optional[Dict[str, Any]] = Field(default=None, description="Request body as JSON object")
2125
+
2126
+ return HttpRequestSchema
2127
+
2128
+ # Python executor tool schema (dual-purpose: workflow node + AI tool)
2129
+ if node_type == 'pythonExecutor':
2130
+ class PythonCodeSchema(BaseModel):
2131
+ """Execute Python code for calculations, data processing, and automation.
2132
+
2133
+ Example: {"code": "result = 2 + 2\\noutput = result"}
2134
+ """
2135
+ code: str = Field(
2136
+ description="REQUIRED: Python code string to execute. Must set 'output' variable with the result. Available: input_data (dict), math, json, datetime, Counter, defaultdict. Example: 'output = 2 + 2'"
2137
+ )
2138
+
2139
+ return PythonCodeSchema
2140
+
2141
+ # JavaScript executor tool schema (dual-purpose: workflow node + AI tool)
2142
+ if node_type == 'javascriptExecutor':
2143
+ class JavaScriptCodeSchema(BaseModel):
2144
+ """Execute JavaScript code for calculations, data processing, and automation.
2145
+
2146
+ Example: {"code": "const result = 2 + 2;\\noutput = result;"}
2147
+ """
2148
+ code: str = Field(
2149
+ description="REQUIRED: JavaScript code string to execute. Must set 'output' variable with the result. Available: input_data (object). Example: 'output = 2 + 2;'"
2150
+ )
2151
+
2152
+ return JavaScriptCodeSchema
2153
+
2154
+ # Current time tool schema
2155
+ if node_type == 'currentTimeTool':
2156
+ class CurrentTimeSchema(BaseModel):
2157
+ """Schema for current time tool arguments."""
2158
+ timezone: str = Field(
2159
+ default="UTC",
2160
+ description="Timezone (e.g., UTC, America/New_York, Europe/London)"
2161
+ )
2162
+
2163
+ return CurrentTimeSchema
2164
+
2165
+ # Web search tool schema
2166
+ if node_type == 'webSearchTool':
2167
+ class WebSearchSchema(BaseModel):
2168
+ """Schema for web search tool arguments."""
2169
+ query: str = Field(description="Search query to look up on the web")
2170
+
2171
+ return WebSearchSchema
2172
+
2173
+ # WhatsApp send schema (existing node used as tool)
2174
+ if node_type == 'whatsappSend':
2175
+ class WhatsAppSendSchema(BaseModel):
2176
+ """Send WhatsApp messages to contacts or groups."""
2177
+ recipient_type: str = Field(
2178
+ default="phone",
2179
+ description="Send to: 'phone' for individual or 'group' for group chat"
2180
+ )
2181
+ phone: Optional[str] = Field(
2182
+ default=None,
2183
+ description="Phone number without + prefix (e.g., 1234567890). Required for recipient_type='phone'"
2184
+ )
2185
+ group_id: Optional[str] = Field(
2186
+ default=None,
2187
+ description="Group JID (e.g., 123456789@g.us). Required for recipient_type='group'"
2188
+ )
2189
+ message_type: str = Field(
2190
+ default="text",
2191
+ description="Message type: 'text', 'image', 'video', 'audio', 'document', 'sticker', 'location', 'contact'"
2192
+ )
2193
+ message: Optional[str] = Field(
2194
+ default=None,
2195
+ description="Text message content. Required for message_type='text'"
2196
+ )
2197
+ media_url: Optional[str] = Field(
2198
+ default=None,
2199
+ description="URL for media (image/video/audio/document/sticker)"
2200
+ )
2201
+ caption: Optional[str] = Field(
2202
+ default=None,
2203
+ description="Caption for media messages (image, video, document)"
2204
+ )
2205
+ latitude: Optional[float] = Field(default=None, description="Latitude for location message")
2206
+ longitude: Optional[float] = Field(default=None, description="Longitude for location message")
2207
+ location_name: Optional[str] = Field(default=None, description="Display name for location")
2208
+ address: Optional[str] = Field(default=None, description="Address text for location")
2209
+ contact_name: Optional[str] = Field(default=None, description="Contact card display name")
2210
+ vcard: Optional[str] = Field(default=None, description="vCard 3.0 format string for contact")
2211
+
2212
+ return WhatsAppSendSchema
2213
+
2214
+ # WhatsApp DB schema (existing node used as tool) - query contacts, groups, messages
2215
+ if node_type == 'whatsappDb':
2216
+ class WhatsAppDbSchema(BaseModel):
2217
+ """Query WhatsApp database - contacts, groups, messages.
2218
+
2219
+ Operations:
2220
+ - chat_history: Get messages from a chat (requires phone or group_id)
2221
+ - search_groups: Search groups by name (optional query)
2222
+ - get_group_info: Get group details with participant names (requires group_id)
2223
+ - get_contact_info: Get full contact info for sending/replying (requires phone)
2224
+ - list_contacts: List contacts with saved names (optional query filter)
2225
+ - check_contacts: Check WhatsApp registration (requires phones comma-separated)
2226
+ """
2227
+ operation: str = Field(
2228
+ default="chat_history",
2229
+ description="Operation: 'chat_history', 'search_groups', 'get_group_info', 'get_contact_info', 'list_contacts', 'check_contacts'"
2230
+ )
2231
+ # For chat_history
2232
+ chat_type: Optional[str] = Field(
2233
+ default=None,
2234
+ description="For chat_history: 'individual' or 'group'"
2235
+ )
2236
+ phone: Optional[str] = Field(
2237
+ default=None,
2238
+ description="Phone number without + prefix. For chat_history (individual), get_contact_info"
2239
+ )
2240
+ group_id: Optional[str] = Field(
2241
+ default=None,
2242
+ description="Group JID. For chat_history (group), get_group_info"
2243
+ )
2244
+ message_filter: Optional[str] = Field(
2245
+ default=None,
2246
+ description="For chat_history: 'all' or 'text_only'"
2247
+ )
2248
+ group_filter: Optional[str] = Field(
2249
+ default=None,
2250
+ description="For chat_history (group): 'all' or 'contact' to filter by sender"
2251
+ )
2252
+ sender_phone: Optional[str] = Field(
2253
+ default=None,
2254
+ description="For chat_history (group with group_filter='contact'): filter messages from this phone"
2255
+ )
2256
+ limit: Optional[int] = Field(
2257
+ default=None,
2258
+ description="Max results to return. chat_history: 1-500 (default 50), search_groups: 1-50 (default 20), list_contacts: 1-100 (default 50). Use smaller limits to avoid context overflow."
2259
+ )
2260
+ offset: Optional[int] = Field(default=None, description="For chat_history: pagination offset")
2261
+ # For search_groups, list_contacts
2262
+ query: Optional[str] = Field(
2263
+ default=None,
2264
+ description="Search query for search_groups or list_contacts. Use specific queries to narrow results."
2265
+ )
2266
+ # For check_contacts
2267
+ phones: Optional[str] = Field(
2268
+ default=None,
2269
+ description="For check_contacts: comma-separated phone numbers"
2270
+ )
2271
+ # For get_group_info
2272
+ participant_limit: Optional[int] = Field(
2273
+ default=None,
2274
+ description="For get_group_info: max participants to return (1-100, default 50). Large groups may have hundreds of members."
2275
+ )
2276
+
2277
+ return WhatsAppDbSchema
2278
+
2279
+ # Android toolkit schema - dynamic based on connected services
2280
+ # Follows LangChain dynamic tool binding pattern
2281
+ if node_type == 'androidTool':
2282
+ connected_services = params.get('connected_services', [])
2283
+
2284
+ if not connected_services:
2285
+ # No services connected - minimal schema with helpful error
2286
+ class EmptyAndroidSchema(BaseModel):
2287
+ """Android toolkit with no connected services."""
2288
+ query: str = Field(
2289
+ default="status",
2290
+ description="No Android services connected. Connect Android nodes to the toolkit."
2291
+ )
2292
+ return EmptyAndroidSchema
2293
+
2294
+ # Build dynamic service list for schema description
2295
+ from services.android_service import SERVICE_ACTIONS
2296
+
2297
+ service_info = []
2298
+ for svc in connected_services:
2299
+ svc_id = svc.get('service_id') or svc.get('node_type', 'unknown')
2300
+ actions = SERVICE_ACTIONS.get(svc_id, [])
2301
+ action_list = [a['value'] for a in actions] if actions else ['status']
2302
+ service_info.append(f"{svc_id}: {'/'.join(action_list)}")
2303
+
2304
+ services_description = "; ".join(service_info)
2305
+
2306
+ class AndroidToolSchema(BaseModel):
2307
+ """Schema for Android device control via connected services."""
2308
+ service_id: str = Field(
2309
+ description=f"Service to use. Connected: {services_description}"
2310
+ )
2311
+ action: str = Field(
2312
+ description="Action to perform (see service list for available actions)"
2313
+ )
2314
+ parameters: Optional[Dict[str, Any]] = Field(
2315
+ default=None,
2316
+ description="Action parameters. Examples: {package_name: 'com.app'} for app_launcher, {volume: 50} for audio"
2317
+ )
2318
+
2319
+ return AndroidToolSchema
2320
+
2321
+ # Google Maps Geocoding schema (addLocations node as tool)
2322
+ # camelCase to match JSON/frontend convention
2323
+ if node_type == 'addLocations':
2324
+ class GeocodingSchema(BaseModel):
2325
+ """Geocode addresses to coordinates or reverse geocode coordinates to addresses."""
2326
+ service_type: str = Field(
2327
+ default="geocode",
2328
+ description="Operation: 'geocode' (address to coordinates) or 'reverse_geocode' (coordinates to address)"
2329
+ )
2330
+ address: Optional[str] = Field(
2331
+ default=None,
2332
+ description="Address to geocode (e.g., '1600 Amphitheatre Parkway, Mountain View, CA'). Required for service_type='geocode'"
2333
+ )
2334
+ lat: Optional[float] = Field(
2335
+ default=None,
2336
+ description="Latitude for reverse geocoding. Required for service_type='reverse_geocode'"
2337
+ )
2338
+ lng: Optional[float] = Field(
2339
+ default=None,
2340
+ description="Longitude for reverse geocoding. Required for service_type='reverse_geocode'"
2341
+ )
2342
+
2343
+ return GeocodingSchema
2344
+
2345
+ # Google Maps Nearby Places schema (showNearbyPlaces node as tool)
2346
+ # snake_case to match Python convention
2347
+ if node_type == 'showNearbyPlaces':
2348
+ class NearbyPlacesSchema(BaseModel):
2349
+ """Search for nearby places using Google Maps Places API."""
2350
+ lat: float = Field(
2351
+ description="Center latitude for search (e.g., 40.7484)"
2352
+ )
2353
+ lng: float = Field(
2354
+ description="Center longitude for search (e.g., -73.9857)"
2355
+ )
2356
+ radius: int = Field(
2357
+ default=500,
2358
+ description="Search radius in meters (max 50000)"
2359
+ )
2360
+ type: str = Field(
2361
+ default="restaurant",
2362
+ description="Place type: restaurant, cafe, bar, hospital, pharmacy, bank, atm, gas_station, supermarket, park, gym, etc."
2363
+ )
2364
+ keyword: Optional[str] = Field(
2365
+ default=None,
2366
+ description="Optional keyword to filter results (e.g., 'pizza', 'italian', '24 hour')"
2367
+ )
2368
+
2369
+ return NearbyPlacesSchema
2370
+
2371
+ # Generic schema for other nodes
2372
+ class GenericToolSchema(BaseModel):
2373
+ """Generic schema for tool arguments."""
2374
+ input: str = Field(description="Input data for the tool")
2375
+
2376
+ return GenericToolSchema
2377
+
2378
+ def _build_schema_from_config(self, schema_config: Dict[str, Any]) -> Type[BaseModel]:
2379
+ """Build a Pydantic schema from database-stored configuration.
2380
+
2381
+ Schema config format:
2382
+ {
2383
+ "description": "Schema description",
2384
+ "fields": {
2385
+ "field_name": {
2386
+ "type": "string" | "number" | "boolean" | "object" | "array",
2387
+ "description": "Field description",
2388
+ "required": True | False,
2389
+ "default": <optional default value>,
2390
+ "enum": [<optional enum values>]
2391
+ }
2392
+ }
2393
+ }
2394
+ """
2395
+ fields_config = schema_config.get('fields', {})
2396
+ schema_description = schema_config.get('description', 'Tool arguments schema')
2397
+
2398
+ # Build field annotations and defaults
2399
+ annotations = {}
2400
+ field_defaults = {}
2401
+
2402
+ TYPE_MAP = {
2403
+ 'string': str,
2404
+ 'number': float,
2405
+ 'integer': int,
2406
+ 'boolean': bool,
2407
+ 'object': Dict[str, Any],
2408
+ 'array': list,
2409
+ }
2410
+
2411
+ for field_name, field_config in fields_config.items():
2412
+ field_type_str = field_config.get('type', 'string')
2413
+ field_type = TYPE_MAP.get(field_type_str, str)
2414
+ field_description = field_config.get('description', '')
2415
+ is_required = field_config.get('required', True)
2416
+ default_value = field_config.get('default')
2417
+ enum_values = field_config.get('enum')
2418
+
2419
+ # Handle optional fields
2420
+ if not is_required:
2421
+ field_type = Optional[field_type]
2422
+
2423
+ annotations[field_name] = field_type
2424
+
2425
+ # Build Field with description and enum if provided
2426
+ field_kwargs = {'description': field_description}
2427
+ if enum_values:
2428
+ # For enums, include in description since Pydantic Field doesn't support enum directly
2429
+ field_kwargs['description'] = f"{field_description} Options: {', '.join(str(v) for v in enum_values)}"
2430
+
2431
+ if default_value is not None:
2432
+ field_defaults[field_name] = Field(default=default_value, **field_kwargs)
2433
+ elif not is_required:
2434
+ field_defaults[field_name] = Field(default=None, **field_kwargs)
2435
+ else:
2436
+ field_defaults[field_name] = Field(**field_kwargs)
2437
+
2438
+ # Create dynamic Pydantic model
2439
+ DynamicSchema = create_model(
2440
+ 'DynamicToolSchema',
2441
+ __doc__=schema_description,
2442
+ **{name: (annotations[name], field_defaults[name]) for name in annotations}
2443
+ )
2444
+
2415
2445
  return DynamicSchema