machinaos 0.0.1
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.
- package/.env.template +71 -0
- package/LICENSE +21 -0
- package/README.md +87 -0
- package/bin/cli.js +159 -0
- package/client/.dockerignore +45 -0
- package/client/Dockerfile +68 -0
- package/client/eslint.config.js +29 -0
- package/client/index.html +13 -0
- package/client/nginx.conf +66 -0
- package/client/package.json +48 -0
- package/client/src/App.tsx +27 -0
- package/client/src/Dashboard.tsx +1173 -0
- package/client/src/ParameterPanel.tsx +301 -0
- package/client/src/components/AIAgentNode.tsx +321 -0
- package/client/src/components/APIKeyValidator.tsx +118 -0
- package/client/src/components/ClaudeChatModelNode.tsx +18 -0
- package/client/src/components/ConditionalEdge.tsx +189 -0
- package/client/src/components/CredentialsModal.tsx +306 -0
- package/client/src/components/EdgeConditionEditor.tsx +443 -0
- package/client/src/components/GeminiChatModelNode.tsx +18 -0
- package/client/src/components/GenericNode.tsx +357 -0
- package/client/src/components/LocationParameterPanel.tsx +154 -0
- package/client/src/components/ModelNode.tsx +286 -0
- package/client/src/components/OpenAIChatModelNode.tsx +18 -0
- package/client/src/components/OutputPanel.tsx +471 -0
- package/client/src/components/ParameterRenderer.tsx +1874 -0
- package/client/src/components/SkillEditorModal.tsx +417 -0
- package/client/src/components/SquareNode.tsx +797 -0
- package/client/src/components/StartNode.tsx +250 -0
- package/client/src/components/ToolkitNode.tsx +365 -0
- package/client/src/components/TriggerNode.tsx +463 -0
- package/client/src/components/auth/LoginPage.tsx +247 -0
- package/client/src/components/auth/ProtectedRoute.tsx +59 -0
- package/client/src/components/base/BaseChatModelNode.tsx +271 -0
- package/client/src/components/icons/AIProviderIcons.tsx +50 -0
- package/client/src/components/maps/GoogleMapsPicker.tsx +137 -0
- package/client/src/components/maps/MapsPreviewPanel.tsx +110 -0
- package/client/src/components/maps/index.ts +26 -0
- package/client/src/components/parameterPanel/InputSection.tsx +1094 -0
- package/client/src/components/parameterPanel/LocationPanelLayout.tsx +65 -0
- package/client/src/components/parameterPanel/MapsSection.tsx +92 -0
- package/client/src/components/parameterPanel/MiddleSection.tsx +571 -0
- package/client/src/components/parameterPanel/OutputSection.tsx +81 -0
- package/client/src/components/parameterPanel/ParameterPanelLayout.tsx +82 -0
- package/client/src/components/parameterPanel/ToolSchemaEditor.tsx +436 -0
- package/client/src/components/parameterPanel/index.ts +42 -0
- package/client/src/components/shared/DataPanel.tsx +142 -0
- package/client/src/components/shared/JSONTreeRenderer.tsx +106 -0
- package/client/src/components/ui/AIResultModal.tsx +204 -0
- package/client/src/components/ui/AndroidSettingsPanel.tsx +401 -0
- package/client/src/components/ui/CodeEditor.tsx +81 -0
- package/client/src/components/ui/CollapsibleSection.tsx +88 -0
- package/client/src/components/ui/ComponentItem.tsx +154 -0
- package/client/src/components/ui/ComponentPalette.tsx +321 -0
- package/client/src/components/ui/ConsolePanel.tsx +1074 -0
- package/client/src/components/ui/ErrorBoundary.tsx +196 -0
- package/client/src/components/ui/InputNodesPanel.tsx +204 -0
- package/client/src/components/ui/MapSelector.tsx +314 -0
- package/client/src/components/ui/Modal.tsx +149 -0
- package/client/src/components/ui/NodeContextMenu.tsx +192 -0
- package/client/src/components/ui/NodeOutputPanel.tsx +1150 -0
- package/client/src/components/ui/OutputDisplayPanel.tsx +381 -0
- package/client/src/components/ui/SettingsPanel.tsx +243 -0
- package/client/src/components/ui/TopToolbar.tsx +736 -0
- package/client/src/components/ui/WhatsAppSettingsPanel.tsx +345 -0
- package/client/src/components/ui/WorkflowSidebar.tsx +294 -0
- package/client/src/config/antdTheme.ts +186 -0
- package/client/src/config/api.ts +54 -0
- package/client/src/contexts/AuthContext.tsx +221 -0
- package/client/src/contexts/ThemeContext.tsx +42 -0
- package/client/src/contexts/WebSocketContext.tsx +1971 -0
- package/client/src/factories/baseChatModelFactory.ts +256 -0
- package/client/src/hooks/useAndroidOperations.ts +164 -0
- package/client/src/hooks/useApiKeyValidation.ts +107 -0
- package/client/src/hooks/useApiKeys.ts +238 -0
- package/client/src/hooks/useAppTheme.ts +17 -0
- package/client/src/hooks/useComponentPalette.ts +51 -0
- package/client/src/hooks/useCopyPaste.ts +155 -0
- package/client/src/hooks/useDragAndDrop.ts +124 -0
- package/client/src/hooks/useDragVariable.ts +88 -0
- package/client/src/hooks/useExecution.ts +313 -0
- package/client/src/hooks/useParameterPanel.ts +176 -0
- package/client/src/hooks/useReactFlowNodes.ts +189 -0
- package/client/src/hooks/useToolSchema.ts +209 -0
- package/client/src/hooks/useWhatsApp.ts +196 -0
- package/client/src/hooks/useWorkflowManagement.ts +46 -0
- package/client/src/index.css +315 -0
- package/client/src/main.tsx +19 -0
- package/client/src/nodeDefinitions/aiAgentNodes.ts +336 -0
- package/client/src/nodeDefinitions/aiModelNodes.ts +340 -0
- package/client/src/nodeDefinitions/androidDeviceNodes.ts +140 -0
- package/client/src/nodeDefinitions/androidServiceNodes.ts +383 -0
- package/client/src/nodeDefinitions/chatNodes.ts +135 -0
- package/client/src/nodeDefinitions/codeNodes.ts +54 -0
- package/client/src/nodeDefinitions/documentNodes.ts +379 -0
- package/client/src/nodeDefinitions/index.ts +15 -0
- package/client/src/nodeDefinitions/locationNodes.ts +463 -0
- package/client/src/nodeDefinitions/schedulerNodes.ts +220 -0
- package/client/src/nodeDefinitions/skillNodes.ts +211 -0
- package/client/src/nodeDefinitions/toolNodes.ts +198 -0
- package/client/src/nodeDefinitions/utilityNodes.ts +284 -0
- package/client/src/nodeDefinitions/whatsappNodes.ts +865 -0
- package/client/src/nodeDefinitions/workflowNodes.ts +41 -0
- package/client/src/nodeDefinitions.ts +104 -0
- package/client/src/schemas/workflowSchema.ts +264 -0
- package/client/src/services/dynamicParameterService.ts +96 -0
- package/client/src/services/execution/aiAgentExecutionService.ts +35 -0
- package/client/src/services/executionService.ts +232 -0
- package/client/src/services/workflowApi.ts +91 -0
- package/client/src/store/useAppStore.ts +582 -0
- package/client/src/styles/theme.ts +508 -0
- package/client/src/styles/zIndex.ts +17 -0
- package/client/src/types/ComponentTypes.ts +39 -0
- package/client/src/types/EdgeCondition.ts +231 -0
- package/client/src/types/INodeProperties.ts +288 -0
- package/client/src/types/NodeTypes.ts +28 -0
- package/client/src/utils/formatters.ts +33 -0
- package/client/src/utils/googleMapsLoader.ts +140 -0
- package/client/src/utils/locationUtils.ts +85 -0
- package/client/src/utils/nodeUtils.ts +31 -0
- package/client/src/utils/workflow.ts +30 -0
- package/client/src/utils/workflowExport.ts +120 -0
- package/client/src/vite-env.d.ts +12 -0
- package/client/tailwind.config.js +60 -0
- package/client/tsconfig.json +25 -0
- package/client/tsconfig.node.json +11 -0
- package/client/vite.config.js +35 -0
- package/docker-compose.prod.yml +107 -0
- package/docker-compose.yml +104 -0
- package/docs-MachinaOs/README.md +85 -0
- package/docs-MachinaOs/deployment/docker.mdx +228 -0
- package/docs-MachinaOs/deployment/production.mdx +345 -0
- package/docs-MachinaOs/docs.json +75 -0
- package/docs-MachinaOs/faq.mdx +309 -0
- package/docs-MachinaOs/favicon.svg +5 -0
- package/docs-MachinaOs/installation.mdx +160 -0
- package/docs-MachinaOs/introduction.mdx +114 -0
- package/docs-MachinaOs/logo/dark.svg +6 -0
- package/docs-MachinaOs/logo/light.svg +6 -0
- package/docs-MachinaOs/nodes/ai-agent.mdx +216 -0
- package/docs-MachinaOs/nodes/ai-models.mdx +240 -0
- package/docs-MachinaOs/nodes/android.mdx +411 -0
- package/docs-MachinaOs/nodes/overview.mdx +181 -0
- package/docs-MachinaOs/nodes/schedulers.mdx +316 -0
- package/docs-MachinaOs/nodes/webhooks.mdx +330 -0
- package/docs-MachinaOs/nodes/whatsapp.mdx +305 -0
- package/docs-MachinaOs/quickstart.mdx +119 -0
- package/docs-MachinaOs/tutorials/ai-agent-workflow.mdx +177 -0
- package/docs-MachinaOs/tutorials/android-automation.mdx +242 -0
- package/docs-MachinaOs/tutorials/first-workflow.mdx +134 -0
- package/docs-MachinaOs/tutorials/whatsapp-automation.mdx +185 -0
- package/nul +0 -0
- package/package.json +70 -0
- package/scripts/build.js +158 -0
- package/scripts/check-ports.ps1 +33 -0
- package/scripts/clean.js +40 -0
- package/scripts/docker.js +93 -0
- package/scripts/kill-port.ps1 +154 -0
- package/scripts/start.js +210 -0
- package/scripts/stop.js +325 -0
- package/server/.dockerignore +44 -0
- package/server/Dockerfile +45 -0
- package/server/constants.py +249 -0
- package/server/core/__init__.py +1 -0
- package/server/core/cache.py +461 -0
- package/server/core/config.py +128 -0
- package/server/core/container.py +99 -0
- package/server/core/database.py +1211 -0
- package/server/core/logging.py +314 -0
- package/server/main.py +289 -0
- package/server/middleware/__init__.py +5 -0
- package/server/middleware/auth.py +89 -0
- package/server/models/__init__.py +1 -0
- package/server/models/auth.py +52 -0
- package/server/models/cache.py +24 -0
- package/server/models/database.py +211 -0
- package/server/models/nodes.py +455 -0
- package/server/package.json +9 -0
- package/server/pyproject.toml +72 -0
- package/server/requirements.txt +83 -0
- package/server/routers/__init__.py +1 -0
- package/server/routers/android.py +294 -0
- package/server/routers/auth.py +203 -0
- package/server/routers/database.py +151 -0
- package/server/routers/maps.py +142 -0
- package/server/routers/nodejs_compat.py +289 -0
- package/server/routers/webhook.py +90 -0
- package/server/routers/websocket.py +2127 -0
- package/server/routers/whatsapp.py +761 -0
- package/server/routers/workflow.py +200 -0
- package/server/services/__init__.py +1 -0
- package/server/services/ai.py +2415 -0
- package/server/services/android/__init__.py +27 -0
- package/server/services/android/broadcaster.py +114 -0
- package/server/services/android/client.py +608 -0
- package/server/services/android/manager.py +78 -0
- package/server/services/android/protocol.py +165 -0
- package/server/services/android_service.py +588 -0
- package/server/services/auth.py +131 -0
- package/server/services/chat_client.py +160 -0
- package/server/services/deployment/__init__.py +12 -0
- package/server/services/deployment/manager.py +706 -0
- package/server/services/deployment/state.py +47 -0
- package/server/services/deployment/triggers.py +275 -0
- package/server/services/event_waiter.py +785 -0
- package/server/services/execution/__init__.py +77 -0
- package/server/services/execution/cache.py +769 -0
- package/server/services/execution/conditions.py +373 -0
- package/server/services/execution/dlq.py +132 -0
- package/server/services/execution/executor.py +1351 -0
- package/server/services/execution/models.py +531 -0
- package/server/services/execution/recovery.py +235 -0
- package/server/services/handlers/__init__.py +126 -0
- package/server/services/handlers/ai.py +355 -0
- package/server/services/handlers/android.py +260 -0
- package/server/services/handlers/code.py +278 -0
- package/server/services/handlers/document.py +598 -0
- package/server/services/handlers/http.py +193 -0
- package/server/services/handlers/polyglot.py +105 -0
- package/server/services/handlers/tools.py +845 -0
- package/server/services/handlers/triggers.py +107 -0
- package/server/services/handlers/utility.py +822 -0
- package/server/services/handlers/whatsapp.py +476 -0
- package/server/services/maps.py +289 -0
- package/server/services/memory_store.py +103 -0
- package/server/services/node_executor.py +375 -0
- package/server/services/parameter_resolver.py +218 -0
- package/server/services/polyglot_client.py +169 -0
- package/server/services/scheduler.py +155 -0
- package/server/services/skill_loader.py +417 -0
- package/server/services/status_broadcaster.py +826 -0
- package/server/services/temporal/__init__.py +23 -0
- package/server/services/temporal/activities.py +344 -0
- package/server/services/temporal/client.py +76 -0
- package/server/services/temporal/executor.py +147 -0
- package/server/services/temporal/worker.py +251 -0
- package/server/services/temporal/workflow.py +355 -0
- package/server/services/temporal/ws_client.py +236 -0
- package/server/services/text.py +111 -0
- package/server/services/user_auth.py +172 -0
- package/server/services/websocket_client.py +29 -0
- package/server/services/workflow.py +597 -0
- package/server/skills/android-skill/SKILL.md +82 -0
- package/server/skills/assistant-personality/SKILL.md +45 -0
- package/server/skills/code-skill/SKILL.md +140 -0
- package/server/skills/http-skill/SKILL.md +161 -0
- package/server/skills/maps-skill/SKILL.md +170 -0
- package/server/skills/memory-skill/SKILL.md +154 -0
- package/server/skills/scheduler-skill/SKILL.md +84 -0
- package/server/skills/whatsapp-skill/SKILL.md +283 -0
- package/server/uv.lock +2916 -0
- package/server/whatsapp-rpc/.dockerignore +30 -0
- package/server/whatsapp-rpc/Dockerfile +44 -0
- package/server/whatsapp-rpc/Dockerfile.web +17 -0
- package/server/whatsapp-rpc/README.md +139 -0
- package/server/whatsapp-rpc/cli.js +95 -0
- package/server/whatsapp-rpc/configs/config.yaml +7 -0
- package/server/whatsapp-rpc/docker-compose.yml +35 -0
- package/server/whatsapp-rpc/docs/API.md +410 -0
- package/server/whatsapp-rpc/go.mod +67 -0
- package/server/whatsapp-rpc/go.sum +203 -0
- package/server/whatsapp-rpc/package.json +30 -0
- package/server/whatsapp-rpc/schema.json +1294 -0
- package/server/whatsapp-rpc/scripts/clean.cjs +66 -0
- package/server/whatsapp-rpc/scripts/cli.js +162 -0
- package/server/whatsapp-rpc/src/go/cmd/server/main.go +91 -0
- package/server/whatsapp-rpc/src/go/config/config.go +49 -0
- package/server/whatsapp-rpc/src/go/rpc/rpc.go +446 -0
- package/server/whatsapp-rpc/src/go/rpc/server.go +112 -0
- package/server/whatsapp-rpc/src/go/whatsapp/history.go +166 -0
- package/server/whatsapp-rpc/src/go/whatsapp/messages.go +390 -0
- package/server/whatsapp-rpc/src/go/whatsapp/service.go +2130 -0
- package/server/whatsapp-rpc/src/go/whatsapp/types.go +261 -0
- package/server/whatsapp-rpc/src/python/pyproject.toml +15 -0
- package/server/whatsapp-rpc/src/python/whatsapp_rpc/__init__.py +4 -0
- package/server/whatsapp-rpc/src/python/whatsapp_rpc/client.py +427 -0
- package/server/whatsapp-rpc/web/app.py +609 -0
- package/server/whatsapp-rpc/web/requirements.txt +6 -0
- package/server/whatsapp-rpc/web/rpc_client.py +427 -0
- package/server/whatsapp-rpc/web/static/openapi.yaml +59 -0
- package/server/whatsapp-rpc/web/templates/base.html +150 -0
- package/server/whatsapp-rpc/web/templates/contacts.html +240 -0
- package/server/whatsapp-rpc/web/templates/dashboard.html +320 -0
- package/server/whatsapp-rpc/web/templates/groups.html +328 -0
- package/server/whatsapp-rpc/web/templates/messages.html +465 -0
- package/server/whatsapp-rpc/web/templates/messaging.html +681 -0
- package/server/whatsapp-rpc/web/templates/send.html +259 -0
- package/server/whatsapp-rpc/web/templates/settings.html +459 -0
|
@@ -0,0 +1,2415 @@
|
|
|
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
|
+
|
|
2415
|
+
return DynamicSchema
|