fixed integration graph editor ai
This commit is contained in:
parent
3ea85fe57e
commit
7d27ddf6b5
7 changed files with 587 additions and 212 deletions
|
|
@ -128,6 +128,21 @@ class ChatWorkflow(PowerOnModel):
|
||||||
"fk_target": {"db": "poweron_app", "table": "FeatureInstance"},
|
"fk_target": {"db": "poweron_app", "table": "FeatureInstance"},
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
linkedWorkflowId: Optional[str] = Field(
|
||||||
|
None,
|
||||||
|
description=(
|
||||||
|
"Optional foreign key linking this chat to an entity outside the "
|
||||||
|
"ChatWorkflow table (e.g. an Automation2Workflow in the GraphicalEditor "
|
||||||
|
"AI editor chat). NULL for the default workspace chats. Combined with "
|
||||||
|
"featureInstanceId this gives a 1:1 relation entity ↔ chat per feature."
|
||||||
|
),
|
||||||
|
json_schema_extra={
|
||||||
|
"label": "Verknüpfter Workflow",
|
||||||
|
"frontend_type": "text",
|
||||||
|
"frontend_readonly": True,
|
||||||
|
"frontend_required": False,
|
||||||
|
},
|
||||||
|
)
|
||||||
status: str = Field(default="running", description="Current status of the workflow", json_schema_extra={"frontend_type": "select", "frontend_readonly": False, "frontend_required": False, "frontend_options": [
|
status: str = Field(default="running", description="Current status of the workflow", json_schema_extra={"frontend_type": "select", "frontend_readonly": False, "frontend_required": False, "frontend_options": [
|
||||||
{"value": "running", "label": "Running"},
|
{"value": "running", "label": "Running"},
|
||||||
{"value": "completed", "label": "Completed"},
|
{"value": "completed", "label": "Completed"},
|
||||||
|
|
|
||||||
|
|
@ -470,16 +470,63 @@ def share_template(
|
||||||
# -------------------------------------------------------------------------
|
# -------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def _editorChatQueueId(workflowId: str) -> str:
|
||||||
|
"""Deterministic SSE queue id for the editor chat (one active stream per workflow).
|
||||||
|
|
||||||
|
Mirrors the workspace pattern (``workspace-{workflowId}``) so stop/cancel can
|
||||||
|
target the running task by workflowId without needing per-request handles.
|
||||||
|
"""
|
||||||
|
return f"ge-chat-{workflowId}"
|
||||||
|
|
||||||
|
|
||||||
|
def _getEditorChatInterface(context: RequestContext, mandateId: str, instanceId: str):
|
||||||
|
"""Build the ChatObjects interface used to persist editor-chat messages."""
|
||||||
|
from modules.interfaces import interfaceDbChat
|
||||||
|
return interfaceDbChat.getInterface(
|
||||||
|
context.user,
|
||||||
|
mandateId=mandateId,
|
||||||
|
featureInstanceId=instanceId,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _editorConversationHistoryFromPersisted(chatInterface, chatWorkflowId: str) -> List[Dict[str, Any]]:
|
||||||
|
"""Load persisted ChatMessages for the editor chat and shape them as the
|
||||||
|
agent expects (``[{role, message}]``). Skips empty / system messages.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
msgs = chatInterface.getMessages(chatWorkflowId) or []
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning("Editor chat: could not load persisted history for %s: %s", chatWorkflowId, e)
|
||||||
|
return []
|
||||||
|
history: List[Dict[str, Any]] = []
|
||||||
|
for m in msgs:
|
||||||
|
role = (getattr(m, "role", None) or (m.get("role") if isinstance(m, dict) else None) or "").strip()
|
||||||
|
text = (getattr(m, "message", None) or (m.get("message") if isinstance(m, dict) else None) or "").strip()
|
||||||
|
if not role or not text:
|
||||||
|
continue
|
||||||
|
if role not in ("user", "assistant", "system"):
|
||||||
|
continue
|
||||||
|
history.append({"role": role, "message": text})
|
||||||
|
return history
|
||||||
|
|
||||||
|
|
||||||
@router.post("/{instanceId}/{workflowId}/chat/stream")
|
@router.post("/{instanceId}/{workflowId}/chat/stream")
|
||||||
@limiter.limit("30/minute")
|
@limiter.limit("30/minute")
|
||||||
async def post_editor_chat(
|
async def post_editor_chat(
|
||||||
request: Request,
|
request: Request,
|
||||||
instanceId: str = Path(..., description="Feature instance ID"),
|
instanceId: str = Path(..., description="Feature instance ID"),
|
||||||
workflowId: str = Path(..., description="Workflow ID"),
|
workflowId: str = Path(..., description="Workflow ID"),
|
||||||
body: dict = Body(..., description="{ message, conversationHistory?, userLanguage? }"),
|
body: dict = Body(..., description="{ message, userLanguage? }"),
|
||||||
context: RequestContext = Depends(getRequestContext),
|
context: RequestContext = Depends(getRequestContext),
|
||||||
):
|
):
|
||||||
"""AI chat endpoint for the editor with SSE streaming. Uses workflow tools to mutate the graph."""
|
"""AI chat endpoint for the editor with SSE streaming. Uses workflow tools to mutate the graph.
|
||||||
|
|
||||||
|
Persistence: the chat is stored in the standard ``ChatWorkflow`` table linked
|
||||||
|
to this Automation2Workflow via ``ChatWorkflow.linkedWorkflowId``. The user
|
||||||
|
message is persisted before the agent starts; the assistant message after.
|
||||||
|
Conversation history is loaded server-side from this linked ChatWorkflow —
|
||||||
|
the client does not need to maintain it.
|
||||||
|
"""
|
||||||
mandateId = _validateInstanceAccess(instanceId, context)
|
mandateId = _validateInstanceAccess(instanceId, context)
|
||||||
message = body.get("message", "")
|
message = body.get("message", "")
|
||||||
if not message:
|
if not message:
|
||||||
|
|
@ -491,14 +538,35 @@ async def post_editor_chat(
|
||||||
raise HTTPException(status_code=404, detail=routeApiMsg("Workflow not found"))
|
raise HTTPException(status_code=404, detail=routeApiMsg("Workflow not found"))
|
||||||
|
|
||||||
userLanguage = body.get("userLanguage", "de")
|
userLanguage = body.get("userLanguage", "de")
|
||||||
conversationHistory = body.get("conversationHistory") or []
|
|
||||||
fileIds = body.get("fileIds") or []
|
fileIds = body.get("fileIds") or []
|
||||||
dataSourceIds = body.get("dataSourceIds") or []
|
dataSourceIds = body.get("dataSourceIds") or []
|
||||||
featureDataSourceIds = body.get("featureDataSourceIds") or []
|
featureDataSourceIds = body.get("featureDataSourceIds") or []
|
||||||
|
|
||||||
|
chatInterface = _getEditorChatInterface(context, mandateId, instanceId)
|
||||||
|
wfLabel = wf.get("label") if isinstance(wf, dict) else getattr(wf, "label", None)
|
||||||
|
chatWorkflow = chatInterface.getOrCreateLinkedWorkflow(
|
||||||
|
featureInstanceId=instanceId,
|
||||||
|
linkedWorkflowId=workflowId,
|
||||||
|
name=wfLabel or f"Editor Chat ({workflowId})",
|
||||||
|
)
|
||||||
|
chatWorkflowId = chatWorkflow.id if hasattr(chatWorkflow, "id") else chatWorkflow.get("id")
|
||||||
|
|
||||||
|
conversationHistory = _editorConversationHistoryFromPersisted(chatInterface, chatWorkflowId)
|
||||||
|
|
||||||
|
try:
|
||||||
|
chatInterface.createMessage({
|
||||||
|
"workflowId": chatWorkflowId,
|
||||||
|
"role": "user",
|
||||||
|
"message": message,
|
||||||
|
"status": "first" if not conversationHistory else "step",
|
||||||
|
})
|
||||||
|
except Exception as e:
|
||||||
|
logger.error("Editor chat: failed to persist user message: %s", e)
|
||||||
|
|
||||||
from modules.serviceCenter.core.serviceStreaming import get_event_manager
|
from modules.serviceCenter.core.serviceStreaming import get_event_manager
|
||||||
sseEventManager = get_event_manager()
|
sseEventManager = get_event_manager()
|
||||||
queueId = f"ge-chat-{workflowId}-{id(request)}"
|
queueId = _editorChatQueueId(workflowId)
|
||||||
|
await sseEventManager.cancel_agent(queueId)
|
||||||
sseEventManager.create_queue(queueId)
|
sseEventManager.create_queue(queueId)
|
||||||
|
|
||||||
agentTask = asyncio.ensure_future(
|
agentTask = asyncio.ensure_future(
|
||||||
|
|
@ -515,6 +583,8 @@ async def post_editor_chat(
|
||||||
fileIds=fileIds,
|
fileIds=fileIds,
|
||||||
dataSourceIds=dataSourceIds,
|
dataSourceIds=dataSourceIds,
|
||||||
featureDataSourceIds=featureDataSourceIds,
|
featureDataSourceIds=featureDataSourceIds,
|
||||||
|
chatInterface=chatInterface,
|
||||||
|
chatWorkflowId=chatWorkflowId,
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
sseEventManager.register_agent_task(queueId, agentTask)
|
sseEventManager.register_agent_task(queueId, agentTask)
|
||||||
|
|
@ -549,6 +619,80 @@ async def post_editor_chat(
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/{instanceId}/{workflowId}/chat/messages")
|
||||||
|
@limiter.limit("120/minute")
|
||||||
|
def get_editor_chat_messages(
|
||||||
|
request: Request,
|
||||||
|
instanceId: str = Path(..., description="Feature instance ID"),
|
||||||
|
workflowId: str = Path(..., description="Workflow ID (Automation2Workflow)"),
|
||||||
|
context: RequestContext = Depends(getRequestContext),
|
||||||
|
):
|
||||||
|
"""Return persisted editor-chat messages for an Automation2Workflow.
|
||||||
|
|
||||||
|
The chat is stored in ``ChatWorkflow`` with ``linkedWorkflowId == workflowId``;
|
||||||
|
if no chat has been started yet for this workflow we return an empty list (we
|
||||||
|
do NOT eagerly create one — the row is created on the first POST /chat/stream).
|
||||||
|
"""
|
||||||
|
mandateId = _validateInstanceAccess(instanceId, context)
|
||||||
|
chatInterface = _getEditorChatInterface(context, mandateId, instanceId)
|
||||||
|
chatWorkflow = chatInterface.getWorkflowByLink(
|
||||||
|
featureInstanceId=instanceId,
|
||||||
|
linkedWorkflowId=workflowId,
|
||||||
|
)
|
||||||
|
if not chatWorkflow:
|
||||||
|
return JSONResponse({
|
||||||
|
"chatWorkflowId": None,
|
||||||
|
"messages": [],
|
||||||
|
})
|
||||||
|
|
||||||
|
chatWorkflowId = chatWorkflow.id if hasattr(chatWorkflow, "id") else chatWorkflow.get("id")
|
||||||
|
rawMessages = chatInterface.getMessages(chatWorkflowId) or []
|
||||||
|
|
||||||
|
items: List[Dict[str, Any]] = []
|
||||||
|
for m in rawMessages:
|
||||||
|
getter = (lambda key, default=None: getattr(m, key, default)) if not isinstance(m, dict) else (lambda key, default=None: m.get(key, default))
|
||||||
|
role = (getter("role") or "").strip()
|
||||||
|
content = (getter("message") or "").strip()
|
||||||
|
if not role or not content:
|
||||||
|
continue
|
||||||
|
items.append({
|
||||||
|
"id": getter("id"),
|
||||||
|
"role": role,
|
||||||
|
"content": content,
|
||||||
|
"timestamp": getter("publishedAt") or 0,
|
||||||
|
"sequenceNr": getter("sequenceNr") or 0,
|
||||||
|
})
|
||||||
|
|
||||||
|
items.sort(key=lambda x: (float(x.get("timestamp") or 0), int(x.get("sequenceNr") or 0)))
|
||||||
|
|
||||||
|
return JSONResponse({
|
||||||
|
"chatWorkflowId": chatWorkflowId,
|
||||||
|
"messages": items,
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/{instanceId}/{workflowId}/chat/stop")
|
||||||
|
@limiter.limit("120/minute")
|
||||||
|
async def post_editor_chat_stop(
|
||||||
|
request: Request,
|
||||||
|
instanceId: str = Path(..., description="Feature instance ID"),
|
||||||
|
workflowId: str = Path(..., description="Workflow ID"),
|
||||||
|
context: RequestContext = Depends(getRequestContext),
|
||||||
|
):
|
||||||
|
"""Stop a running editor-chat agent for the given workflow."""
|
||||||
|
_validateInstanceAccess(instanceId, context)
|
||||||
|
from modules.serviceCenter.core.serviceStreaming import get_event_manager
|
||||||
|
sseEventManager = get_event_manager()
|
||||||
|
queueId = _editorChatQueueId(workflowId)
|
||||||
|
cancelled = await sseEventManager.cancel_agent(queueId)
|
||||||
|
await sseEventManager.emit_event(queueId, "stopped", {
|
||||||
|
"type": "stopped",
|
||||||
|
"workflowId": workflowId,
|
||||||
|
})
|
||||||
|
logger.info("Editor chat stop requested for workflow %s, cancelled=%s", workflowId, cancelled)
|
||||||
|
return JSONResponse({"status": "stopped", "workflowId": workflowId, "cancelled": cancelled})
|
||||||
|
|
||||||
|
|
||||||
async def _runEditorAgent(
|
async def _runEditorAgent(
|
||||||
workflowId: str,
|
workflowId: str,
|
||||||
queueId: str,
|
queueId: str,
|
||||||
|
|
@ -562,12 +706,41 @@ async def _runEditorAgent(
|
||||||
fileIds: List[str] = None,
|
fileIds: List[str] = None,
|
||||||
dataSourceIds: List[str] = None,
|
dataSourceIds: List[str] = None,
|
||||||
featureDataSourceIds: List[str] = None,
|
featureDataSourceIds: List[str] = None,
|
||||||
|
chatInterface=None,
|
||||||
|
chatWorkflowId: Optional[str] = None,
|
||||||
):
|
):
|
||||||
"""Run the serviceAgent loop with workflow toolbox and forward events to the SSE queue."""
|
"""Run the serviceAgent loop with workflow toolbox and forward events to the SSE queue.
|
||||||
|
|
||||||
|
Persists the assistant response to ``ChatMessage`` (linked via ``chatWorkflowId``)
|
||||||
|
on FINAL/ERROR. On cancellation any partial accumulated text is still saved so
|
||||||
|
the editor chat history reflects what the user actually saw on screen.
|
||||||
|
"""
|
||||||
|
assistantPersisted = False
|
||||||
|
|
||||||
|
def _persistAssistant(text: str) -> None:
|
||||||
|
nonlocal assistantPersisted
|
||||||
|
if assistantPersisted or not chatInterface or not chatWorkflowId:
|
||||||
|
return
|
||||||
|
cleaned = (text or "").strip()
|
||||||
|
if not cleaned:
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
chatInterface.createMessage({
|
||||||
|
"workflowId": chatWorkflowId,
|
||||||
|
"role": "assistant",
|
||||||
|
"message": cleaned,
|
||||||
|
"status": "last",
|
||||||
|
})
|
||||||
|
assistantPersisted = True
|
||||||
|
except Exception as msgErr:
|
||||||
|
logger.error("Editor chat: failed to persist assistant message: %s", msgErr)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
from modules.serviceCenter import getService
|
from modules.serviceCenter import getService
|
||||||
from modules.serviceCenter.context import ServiceCenterContext
|
from modules.serviceCenter.context import ServiceCenterContext
|
||||||
from modules.serviceCenter.services.serviceAgent.datamodelAgent import AgentEventTypeEnum
|
from modules.serviceCenter.services.serviceAgent.datamodelAgent import (
|
||||||
|
AgentEventTypeEnum, AgentConfig,
|
||||||
|
)
|
||||||
|
|
||||||
ctx = ServiceCenterContext(
|
ctx = ServiceCenterContext(
|
||||||
user=user,
|
user=user,
|
||||||
|
|
@ -579,11 +752,22 @@ async def _runEditorAgent(
|
||||||
agentService = getService("agent", ctx)
|
agentService = getService("agent", ctx)
|
||||||
|
|
||||||
systemPrompt = (
|
systemPrompt = (
|
||||||
"You are a workflow editor assistant. The user describes changes to a workflow graph. "
|
"You are a workflow EDITOR assistant for the GraphicalEditor. "
|
||||||
"Use the available workflow tools (readWorkflowGraph, addNode, removeNode, connectNodes, "
|
"Your ONLY job is to BUILD or MODIFY the workflow graph (nodes + connections) "
|
||||||
"setNodeParameter, listAvailableNodeTypes, validateGraph) to modify the graph. "
|
"for the user — you must NEVER execute the workflow or any of its actions. "
|
||||||
"Always read the current graph first before making changes. "
|
"Even when the user says 'create a workflow that sends an email', you build the "
|
||||||
"Respond concisely and confirm what you changed."
|
"graph (e.g. add an email node, connect it) — you do NOT actually send an email. "
|
||||||
|
"Use these workflow tools to mutate the graph: "
|
||||||
|
"readWorkflowGraph, listAvailableNodeTypes, addNode, removeNode, connectNodes, "
|
||||||
|
"setNodeParameter, validateGraph. "
|
||||||
|
"Always read the current graph and list available node types first, then plan the "
|
||||||
|
"smallest set of mutations, then apply them. Respond concisely in the user's "
|
||||||
|
"language and confirm what you changed in the graph."
|
||||||
|
)
|
||||||
|
|
||||||
|
editorConfig = AgentConfig(
|
||||||
|
toolSet="core",
|
||||||
|
excludeActionTools=True,
|
||||||
)
|
)
|
||||||
|
|
||||||
enrichedPrompt = prompt
|
enrichedPrompt = prompt
|
||||||
|
|
@ -605,6 +789,7 @@ async def _runEditorAgent(
|
||||||
async for event in agentService.runAgent(
|
async for event in agentService.runAgent(
|
||||||
prompt=enrichedPrompt,
|
prompt=enrichedPrompt,
|
||||||
fileIds=fileIds or [],
|
fileIds=fileIds or [],
|
||||||
|
config=editorConfig,
|
||||||
workflowId=workflowId,
|
workflowId=workflowId,
|
||||||
userLanguage=userLanguage,
|
userLanguage=userLanguage,
|
||||||
conversationHistory=conversationHistory or [],
|
conversationHistory=conversationHistory or [],
|
||||||
|
|
@ -631,8 +816,13 @@ async def _runEditorAgent(
|
||||||
await sseEventManager.emit_event(queueId, sseEvent["type"], sseEvent)
|
await sseEventManager.emit_event(queueId, sseEvent["type"], sseEvent)
|
||||||
|
|
||||||
if event.type in (AgentEventTypeEnum.FINAL, AgentEventTypeEnum.ERROR):
|
if event.type in (AgentEventTypeEnum.FINAL, AgentEventTypeEnum.ERROR):
|
||||||
|
_persistAssistant(event.content or accumulatedText)
|
||||||
break
|
break
|
||||||
|
|
||||||
|
# Fallback: any streamed content not yet stored (cancellation path, no FINAL).
|
||||||
|
if not assistantPersisted and accumulatedText.strip():
|
||||||
|
_persistAssistant(accumulatedText)
|
||||||
|
|
||||||
await sseEventManager.emit_event(queueId, "complete", {
|
await sseEventManager.emit_event(queueId, "complete", {
|
||||||
"type": "complete",
|
"type": "complete",
|
||||||
"workflowId": workflowId,
|
"workflowId": workflowId,
|
||||||
|
|
@ -640,6 +830,12 @@ async def _runEditorAgent(
|
||||||
|
|
||||||
except asyncio.CancelledError:
|
except asyncio.CancelledError:
|
||||||
logger.info("Editor chat agent task cancelled for workflow %s", workflowId)
|
logger.info("Editor chat agent task cancelled for workflow %s", workflowId)
|
||||||
|
# Save whatever the user already saw before cancelling so the next reload
|
||||||
|
# shows the same partial answer (matches workspace behaviour).
|
||||||
|
try:
|
||||||
|
_persistAssistant(accumulatedText if "accumulatedText" in locals() else "")
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
await sseEventManager.emit_event(queueId, "stopped", {
|
await sseEventManager.emit_event(queueId, "stopped", {
|
||||||
"type": "stopped",
|
"type": "stopped",
|
||||||
"workflowId": workflowId,
|
"workflowId": workflowId,
|
||||||
|
|
|
||||||
|
|
@ -655,17 +655,27 @@ class ChatObjects:
|
||||||
totalPages=totalPages
|
totalPages=totalPages
|
||||||
)
|
)
|
||||||
|
|
||||||
def getLastMessageTimestamp(self, workflowId: str) -> Optional[str]:
|
def getLastMessageTimestamp(self, workflowId: str) -> Optional[float]:
|
||||||
"""Return the latest publishedAt/sysCreatedAt from ChatMessage for a workflow."""
|
"""
|
||||||
|
Return the latest publishedAt/sysCreatedAt from ChatMessage for a workflow
|
||||||
|
as UTC seconds (float) — matches the timestamp format used across the
|
||||||
|
rest of the chat data model (lastActivity, startedAt, publishedAt).
|
||||||
|
"""
|
||||||
messages = self._getRecordset(ChatMessage, recordFilter={"workflowId": workflowId})
|
messages = self._getRecordset(ChatMessage, recordFilter={"workflowId": workflowId})
|
||||||
if not messages:
|
if not messages:
|
||||||
return None
|
return None
|
||||||
latest = None
|
latest: Optional[float] = None
|
||||||
for msg in messages:
|
for msg in messages:
|
||||||
ts = msg.get("publishedAt") or msg.get("sysCreatedAt")
|
raw = msg.get("publishedAt") or msg.get("sysCreatedAt")
|
||||||
if ts and (latest is None or str(ts) > str(latest)):
|
if raw is None:
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
ts = float(raw)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
continue
|
||||||
|
if latest is None or ts > latest:
|
||||||
latest = ts
|
latest = ts
|
||||||
return str(latest) if latest else None
|
return latest
|
||||||
|
|
||||||
def searchWorkflowsByContent(self, query: str, limit: int = 50) -> List[str]:
|
def searchWorkflowsByContent(self, query: str, limit: int = 50) -> List[str]:
|
||||||
"""Return workflow IDs whose messages contain the query string (case-insensitive)."""
|
"""Return workflow IDs whose messages contain the query string (case-insensitive)."""
|
||||||
|
|
@ -712,6 +722,8 @@ class ChatObjects:
|
||||||
|
|
||||||
return ChatWorkflow(
|
return ChatWorkflow(
|
||||||
id=workflow["id"],
|
id=workflow["id"],
|
||||||
|
featureInstanceId=workflow.get("featureInstanceId"),
|
||||||
|
linkedWorkflowId=workflow.get("linkedWorkflowId"),
|
||||||
status=workflow.get("status", "running"),
|
status=workflow.get("status", "running"),
|
||||||
name=workflow.get("name"),
|
name=workflow.get("name"),
|
||||||
currentRound=_toInt(workflow.get("currentRound")),
|
currentRound=_toInt(workflow.get("currentRound")),
|
||||||
|
|
@ -728,6 +740,54 @@ class ChatObjects:
|
||||||
logger.error(f"getWorkflow: data validation failed for {workflowId}: {e}")
|
logger.error(f"getWorkflow: data validation failed for {workflowId}: {e}")
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
def getWorkflowByLink(
|
||||||
|
self,
|
||||||
|
featureInstanceId: str,
|
||||||
|
linkedWorkflowId: str,
|
||||||
|
) -> Optional[ChatWorkflow]:
|
||||||
|
"""Return the ChatWorkflow linked to (featureInstanceId, linkedWorkflowId), if any.
|
||||||
|
|
||||||
|
Used by editor-style features (e.g. GraphicalEditor AI editor chat) to
|
||||||
|
find the persisted chat for a specific external entity (Automation2Workflow).
|
||||||
|
Falls under the same RBAC as ``getWorkflow``.
|
||||||
|
"""
|
||||||
|
if not featureInstanceId or not linkedWorkflowId:
|
||||||
|
return None
|
||||||
|
rows = self._getRecordset(
|
||||||
|
ChatWorkflow,
|
||||||
|
recordFilter={
|
||||||
|
"featureInstanceId": featureInstanceId,
|
||||||
|
"linkedWorkflowId": linkedWorkflowId,
|
||||||
|
},
|
||||||
|
) or []
|
||||||
|
if not rows:
|
||||||
|
return None
|
||||||
|
# Return the most recently active one if multiple ever exist (defensive).
|
||||||
|
rows.sort(key=lambda r: float(r.get("lastActivity") or r.get("startedAt") or 0), reverse=True)
|
||||||
|
return self.getWorkflow(rows[0]["id"])
|
||||||
|
|
||||||
|
def getOrCreateLinkedWorkflow(
|
||||||
|
self,
|
||||||
|
featureInstanceId: str,
|
||||||
|
linkedWorkflowId: str,
|
||||||
|
name: Optional[str] = None,
|
||||||
|
) -> ChatWorkflow:
|
||||||
|
"""Find or create the ChatWorkflow linked to a specific external entity.
|
||||||
|
|
||||||
|
Editor-style features call this once at the start of a chat exchange to
|
||||||
|
guarantee a 1:1 mapping between (featureInstanceId, linkedWorkflowId)
|
||||||
|
and a persisted ChatWorkflow row.
|
||||||
|
"""
|
||||||
|
existing = self.getWorkflowByLink(featureInstanceId, linkedWorkflowId)
|
||||||
|
if existing:
|
||||||
|
return existing
|
||||||
|
return self.createWorkflow({
|
||||||
|
"featureInstanceId": featureInstanceId,
|
||||||
|
"linkedWorkflowId": linkedWorkflowId,
|
||||||
|
"status": "active",
|
||||||
|
"name": name or "",
|
||||||
|
})
|
||||||
|
|
||||||
def createWorkflow(self, workflowData: Dict[str, Any]) -> ChatWorkflow:
|
def createWorkflow(self, workflowData: Dict[str, Any]) -> ChatWorkflow:
|
||||||
"""Creates a new workflow if user has permission."""
|
"""Creates a new workflow if user has permission."""
|
||||||
if not self.checkRbacPermission(ChatWorkflow, "create"):
|
if not self.checkRbacPermission(ChatWorkflow, "create"):
|
||||||
|
|
@ -775,6 +835,8 @@ class ChatObjects:
|
||||||
# Convert to ChatWorkflow model (empty related data for new workflow)
|
# Convert to ChatWorkflow model (empty related data for new workflow)
|
||||||
return ChatWorkflow(
|
return ChatWorkflow(
|
||||||
id=created["id"],
|
id=created["id"],
|
||||||
|
featureInstanceId=created.get("featureInstanceId"),
|
||||||
|
linkedWorkflowId=created.get("linkedWorkflowId"),
|
||||||
status=created.get("status", "running"),
|
status=created.get("status", "running"),
|
||||||
name=created.get("name"),
|
name=created.get("name"),
|
||||||
currentRound=created.get("currentRound", 0) or 0,
|
currentRound=created.get("currentRound", 0) or 0,
|
||||||
|
|
|
||||||
|
|
@ -1404,6 +1404,24 @@ class ComponentObjects:
|
||||||
self._validateFolderName(newName, folder.get("parentId"), excludeFolderId=folderId)
|
self._validateFolderName(newName, folder.get("parentId"), excludeFolderId=folderId)
|
||||||
return self.db.recordModify(FileFolder, folderId, {"name": newName})
|
return self.db.recordModify(FileFolder, folderId, {"name": newName})
|
||||||
|
|
||||||
|
def updateFolder(self, folderId: str, updateData: Dict[str, Any]) -> bool:
|
||||||
|
"""
|
||||||
|
Update folder metadata (e.g. ``scope``, ``neutralize``). Owner-only,
|
||||||
|
same access model as renameFolder/moveFolder. Use ``renameFolder`` for
|
||||||
|
``name`` changes (uniqueness validation) and ``moveFolder`` for
|
||||||
|
``parentId`` changes (cycle/uniqueness validation).
|
||||||
|
"""
|
||||||
|
if not updateData:
|
||||||
|
return True
|
||||||
|
folder = self.getFolder(folderId)
|
||||||
|
if not folder:
|
||||||
|
raise FileNotFoundError(f"Folder {folderId} not found")
|
||||||
|
forbiddenKeys = {"id", "sysCreatedBy", "sysCreatedAt", "sysUpdatedAt"}
|
||||||
|
cleaned: Dict[str, Any] = {k: v for k, v in updateData.items() if k not in forbiddenKeys}
|
||||||
|
if "name" in cleaned:
|
||||||
|
self._validateFolderName(cleaned["name"], folder.get("parentId"), excludeFolderId=folderId)
|
||||||
|
return self.db.recordModify(FileFolder, folderId, cleaned)
|
||||||
|
|
||||||
def moveFolder(self, folderId: str, targetParentId: Optional[str] = None) -> bool:
|
def moveFolder(self, folderId: str, targetParentId: Optional[str] = None) -> bool:
|
||||||
"""Move a folder to a new parent, with circular reference and unique name checks."""
|
"""Move a folder to a new parent, with circular reference and unique name checks."""
|
||||||
folder = self.getFolder(folderId)
|
folder = self.getFolder(folderId)
|
||||||
|
|
|
||||||
|
|
@ -93,6 +93,14 @@ class AgentConfig(BaseModel):
|
||||||
availableToolboxes: List[str] = Field(default_factory=list)
|
availableToolboxes: List[str] = Field(default_factory=list)
|
||||||
temperature: Optional[float] = Field(default=None, ge=0.0, le=2.0)
|
temperature: Optional[float] = Field(default=None, ge=0.0, le=2.0)
|
||||||
operationType: Optional[OperationTypeEnum] = Field(default=None, description="Override the default AGENT operationType for model selection")
|
operationType: Optional[OperationTypeEnum] = Field(default=None, description="Override the default AGENT operationType for model selection")
|
||||||
|
excludeActionTools: bool = Field(
|
||||||
|
default=False,
|
||||||
|
description=(
|
||||||
|
"If True, do NOT register workflow-action methods as agent tools. "
|
||||||
|
"Used by editor-style agents (e.g. GraphicalEditor) that should only "
|
||||||
|
"manipulate the workflow graph, not execute its actions."
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class AgentState(BaseModel):
|
class AgentState(BaseModel):
|
||||||
|
|
|
||||||
|
|
@ -330,6 +330,7 @@ class AgentService:
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.warning("discoverMethods failed before action tools: %s", e)
|
logger.warning("discoverMethods failed before action tools: %s", e)
|
||||||
|
|
||||||
|
if not getattr(config, "excludeActionTools", False):
|
||||||
try:
|
try:
|
||||||
from modules.workflows.processing.core.actionExecutor import ActionExecutor
|
from modules.workflows.processing.core.actionExecutor import ActionExecutor
|
||||||
actionExecutor = ActionExecutor(self.services)
|
actionExecutor = ActionExecutor(self.services)
|
||||||
|
|
@ -337,8 +338,11 @@ class AgentService:
|
||||||
adapter.registerAll(registry)
|
adapter.registerAll(registry)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.warning(f"Could not register action tools: {e}")
|
logger.warning(f"Could not register action tools: {e}")
|
||||||
|
else:
|
||||||
|
logger.info("excludeActionTools=True: skipping ActionToolAdapter registration (editor-mode agent)")
|
||||||
|
|
||||||
self._activateToolboxes(registry, config)
|
self._activateToolboxes(registry, config)
|
||||||
|
if not getattr(config, "excludeActionTools", False):
|
||||||
self._registerRequestToolbox(registry)
|
self._registerRequestToolbox(registry)
|
||||||
|
|
||||||
return registry
|
return registry
|
||||||
|
|
|
||||||
|
|
@ -4,11 +4,21 @@
|
||||||
Workflow Toolbox - AI-assisted graph manipulation tools for the GraphicalEditor.
|
Workflow Toolbox - AI-assisted graph manipulation tools for the GraphicalEditor.
|
||||||
Tools: readWorkflowGraph, addNode, removeNode, connectNodes, setNodeParameter,
|
Tools: readWorkflowGraph, addNode, removeNode, connectNodes, setNodeParameter,
|
||||||
listAvailableNodeTypes, validateGraph, listWorkflowHistory, readWorkflowMessages.
|
listAvailableNodeTypes, validateGraph, listWorkflowHistory, readWorkflowMessages.
|
||||||
|
|
||||||
|
Conventions enforced here (matches coreTools / actionToolAdapter):
|
||||||
|
- Every ``ToolResult(...)`` provides ``toolCallId`` and ``toolName`` (pydantic
|
||||||
|
requires both); ``ToolRegistry.dispatch`` overwrites ``toolCallId`` later
|
||||||
|
but the model still validates at construction.
|
||||||
|
- ``ToolResult.data`` is a ``str``; structured payloads are JSON-encoded.
|
||||||
|
- ``workflowId`` and ``instanceId`` are auto-injected from the agent
|
||||||
|
``context`` dict (``workflowId``, ``featureInstanceId``) when the model
|
||||||
|
omits them — the editor agent always runs in exactly one workflow.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
import json
|
||||||
import logging
|
import logging
|
||||||
import uuid
|
import uuid
|
||||||
from typing import Dict, Any, List, Optional
|
from typing import Dict, Any, List, Tuple
|
||||||
|
|
||||||
from modules.serviceCenter.services.serviceAgent.datamodelAgent import ToolResult
|
from modules.serviceCenter.services.serviceAgent.datamodelAgent import ToolResult
|
||||||
|
|
||||||
|
|
@ -17,65 +27,124 @@ logger = logging.getLogger(__name__)
|
||||||
TOOLBOX_ID = "workflow"
|
TOOLBOX_ID = "workflow"
|
||||||
|
|
||||||
|
|
||||||
|
def _toData(payload: Any) -> str:
|
||||||
|
"""Encode a structured payload into ToolResult.data (which is a string)."""
|
||||||
|
if isinstance(payload, str):
|
||||||
|
return payload
|
||||||
|
try:
|
||||||
|
return json.dumps(payload, default=str, ensure_ascii=False)
|
||||||
|
except Exception:
|
||||||
|
return str(payload)
|
||||||
|
|
||||||
|
|
||||||
|
def _err(toolName: str, message: str) -> ToolResult:
|
||||||
|
return ToolResult(toolCallId="", toolName=toolName, success=False, error=message)
|
||||||
|
|
||||||
|
|
||||||
|
def _ok(toolName: str, payload: Any) -> ToolResult:
|
||||||
|
return ToolResult(toolCallId="", toolName=toolName, success=True, data=_toData(payload))
|
||||||
|
|
||||||
|
|
||||||
|
def _resolveIds(params: Dict[str, Any], context: Any) -> Tuple[str, str]:
|
||||||
|
"""Return (workflowId, instanceId), auto-injecting from context when missing.
|
||||||
|
|
||||||
|
The editor agent context (``agentLoop._executeToolCalls``) is a dict with
|
||||||
|
``workflowId`` and ``featureInstanceId`` — use them as defaults so the
|
||||||
|
model doesn't have to re-state the ids on every tool call.
|
||||||
|
"""
|
||||||
|
ctx: Dict[str, Any] = context if isinstance(context, dict) else {}
|
||||||
|
workflowId = params.get("workflowId") or ctx.get("workflowId") or ""
|
||||||
|
instanceId = (
|
||||||
|
params.get("instanceId")
|
||||||
|
or ctx.get("featureInstanceId")
|
||||||
|
or ctx.get("instanceId")
|
||||||
|
or ""
|
||||||
|
)
|
||||||
|
return workflowId, instanceId
|
||||||
|
|
||||||
|
|
||||||
|
def _resolveUser(context: Any):
|
||||||
|
"""Return the User object for the current agent context (lazy DB fetch)."""
|
||||||
|
if not isinstance(context, dict):
|
||||||
|
return getattr(context, "user", None)
|
||||||
|
user = context.get("user")
|
||||||
|
if user is not None:
|
||||||
|
return user
|
||||||
|
userId = context.get("userId")
|
||||||
|
if not userId:
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
from modules.interfaces.interfaceDbApp import getRootInterface
|
||||||
|
return getRootInterface().getUser(str(userId))
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning("workflowTools: could not resolve user %s: %s", userId, e)
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _resolveMandateId(context: Any) -> str:
|
||||||
|
if not isinstance(context, dict):
|
||||||
|
return getattr(context, "mandateId", "") or ""
|
||||||
|
return context.get("mandateId") or ""
|
||||||
|
|
||||||
|
|
||||||
|
def _getInterface(context: Any, instanceId: str):
|
||||||
|
from modules.features.graphicalEditor.interfaceFeatureGraphicalEditor import getGraphicalEditorInterface
|
||||||
|
return getGraphicalEditorInterface(_resolveUser(context), _resolveMandateId(context), instanceId)
|
||||||
|
|
||||||
|
|
||||||
async def _readWorkflowGraph(params: Dict[str, Any], context: Any) -> ToolResult:
|
async def _readWorkflowGraph(params: Dict[str, Any], context: Any) -> ToolResult:
|
||||||
"""Read the current workflow graph (nodes and connections)."""
|
"""Read the current workflow graph (nodes and connections)."""
|
||||||
|
name = "readWorkflowGraph"
|
||||||
try:
|
try:
|
||||||
workflowId = params.get("workflowId")
|
workflowId, instanceId = _resolveIds(params, context)
|
||||||
instanceId = params.get("instanceId")
|
|
||||||
if not workflowId or not instanceId:
|
if not workflowId or not instanceId:
|
||||||
return ToolResult(success=False, error="workflowId and instanceId required")
|
return _err(name, "workflowId and instanceId required (and not present in agent context)")
|
||||||
|
|
||||||
from modules.features.graphicalEditor.interfaceFeatureGraphicalEditor import getGraphicalEditorInterface
|
iface = _getInterface(context, instanceId)
|
||||||
user = getattr(context, "user", None)
|
|
||||||
mandateId = getattr(context, "mandateId", "") or ""
|
|
||||||
iface = getGraphicalEditorInterface(user, mandateId, instanceId)
|
|
||||||
wf = iface.getWorkflow(workflowId)
|
wf = iface.getWorkflow(workflowId)
|
||||||
if not wf:
|
if not wf:
|
||||||
return ToolResult(success=False, error=f"Workflow {workflowId} not found")
|
return _err(name, f"Workflow {workflowId} not found")
|
||||||
|
|
||||||
graph = wf.get("graph", {})
|
graph = wf.get("graph", {}) or {}
|
||||||
nodes = graph.get("nodes", [])
|
nodes = graph.get("nodes", []) or []
|
||||||
connections = graph.get("connections", [])
|
connections = graph.get("connections", []) or []
|
||||||
return ToolResult(
|
return _ok(name, {
|
||||||
success=True,
|
|
||||||
data={
|
|
||||||
"workflowId": workflowId,
|
"workflowId": workflowId,
|
||||||
"label": wf.get("label", ""),
|
"label": wf.get("label", ""),
|
||||||
"nodeCount": len(nodes),
|
"nodeCount": len(nodes),
|
||||||
"connectionCount": len(connections),
|
"connectionCount": len(connections),
|
||||||
"nodes": [{"id": n.get("id"), "type": n.get("type"), "title": n.get("title", "")} for n in nodes],
|
"nodes": [
|
||||||
|
{"id": n.get("id"), "type": n.get("type"), "title": n.get("title", "")}
|
||||||
|
for n in nodes
|
||||||
|
],
|
||||||
"connections": connections,
|
"connections": connections,
|
||||||
},
|
})
|
||||||
)
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.exception("readWorkflowGraph failed: %s", e)
|
logger.exception("readWorkflowGraph failed: %s", e)
|
||||||
return ToolResult(success=False, error=str(e))
|
return _err(name, str(e))
|
||||||
|
|
||||||
|
|
||||||
async def _addNode(params: Dict[str, Any], context: Any) -> ToolResult:
|
async def _addNode(params: Dict[str, Any], context: Any) -> ToolResult:
|
||||||
"""Add a node to the workflow graph."""
|
"""Add a node to the workflow graph."""
|
||||||
|
name = "addNode"
|
||||||
try:
|
try:
|
||||||
workflowId = params.get("workflowId")
|
workflowId, instanceId = _resolveIds(params, context)
|
||||||
instanceId = params.get("instanceId")
|
|
||||||
nodeType = params.get("nodeType")
|
nodeType = params.get("nodeType")
|
||||||
if not workflowId or not instanceId or not nodeType:
|
if not workflowId or not instanceId or not nodeType:
|
||||||
return ToolResult(success=False, error="workflowId, instanceId, and nodeType required")
|
return _err(name, "workflowId, instanceId, and nodeType required")
|
||||||
|
|
||||||
from modules.features.graphicalEditor.interfaceFeatureGraphicalEditor import getGraphicalEditorInterface
|
iface = _getInterface(context, instanceId)
|
||||||
user = getattr(context, "user", None)
|
|
||||||
mandateId = getattr(context, "mandateId", "") or ""
|
|
||||||
iface = getGraphicalEditorInterface(user, mandateId, instanceId)
|
|
||||||
wf = iface.getWorkflow(workflowId)
|
wf = iface.getWorkflow(workflowId)
|
||||||
if not wf:
|
if not wf:
|
||||||
return ToolResult(success=False, error=f"Workflow {workflowId} not found")
|
return _err(name, f"Workflow {workflowId} not found")
|
||||||
|
|
||||||
graph = dict(wf.get("graph", {}))
|
graph = dict(wf.get("graph", {}) or {})
|
||||||
nodes = list(graph.get("nodes", []))
|
nodes = list(graph.get("nodes", []) or [])
|
||||||
|
|
||||||
nodeId = params.get("nodeId") or str(uuid.uuid4())[:8]
|
nodeId = params.get("nodeId") or str(uuid.uuid4())[:8]
|
||||||
title = params.get("title", "")
|
title = params.get("title", "")
|
||||||
nodeParams = params.get("parameters", {})
|
nodeParams = params.get("parameters", {}) or {}
|
||||||
position = params.get("position", {"x": len(nodes) * 200, "y": 100})
|
position = params.get("position") or {"x": len(nodes) * 200, "y": 100}
|
||||||
|
|
||||||
newNode = {
|
newNode = {
|
||||||
"id": nodeId,
|
"id": nodeId,
|
||||||
|
|
@ -88,68 +157,63 @@ async def _addNode(params: Dict[str, Any], context: Any) -> ToolResult:
|
||||||
graph["nodes"] = nodes
|
graph["nodes"] = nodes
|
||||||
|
|
||||||
iface.updateWorkflow(workflowId, {"graph": graph})
|
iface.updateWorkflow(workflowId, {"graph": graph})
|
||||||
return ToolResult(
|
return _ok(name, {
|
||||||
success=True,
|
"nodeId": nodeId,
|
||||||
data={"nodeId": nodeId, "nodeType": nodeType, "message": f"Node '{title or nodeType}' added"},
|
"nodeType": nodeType,
|
||||||
)
|
"message": f"Node '{title or nodeType}' added",
|
||||||
|
})
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.exception("addNode failed: %s", e)
|
logger.exception("addNode failed: %s", e)
|
||||||
return ToolResult(success=False, error=str(e))
|
return _err(name, str(e))
|
||||||
|
|
||||||
|
|
||||||
async def _removeNode(params: Dict[str, Any], context: Any) -> ToolResult:
|
async def _removeNode(params: Dict[str, Any], context: Any) -> ToolResult:
|
||||||
"""Remove a node and its connections from the workflow graph."""
|
"""Remove a node and its connections from the workflow graph."""
|
||||||
|
name = "removeNode"
|
||||||
try:
|
try:
|
||||||
workflowId = params.get("workflowId")
|
workflowId, instanceId = _resolveIds(params, context)
|
||||||
instanceId = params.get("instanceId")
|
|
||||||
nodeId = params.get("nodeId")
|
nodeId = params.get("nodeId")
|
||||||
if not workflowId or not instanceId or not nodeId:
|
if not workflowId or not instanceId or not nodeId:
|
||||||
return ToolResult(success=False, error="workflowId, instanceId, and nodeId required")
|
return _err(name, "workflowId, instanceId, and nodeId required")
|
||||||
|
|
||||||
from modules.features.graphicalEditor.interfaceFeatureGraphicalEditor import getGraphicalEditorInterface
|
iface = _getInterface(context, instanceId)
|
||||||
user = getattr(context, "user", None)
|
|
||||||
mandateId = getattr(context, "mandateId", "") or ""
|
|
||||||
iface = getGraphicalEditorInterface(user, mandateId, instanceId)
|
|
||||||
wf = iface.getWorkflow(workflowId)
|
wf = iface.getWorkflow(workflowId)
|
||||||
if not wf:
|
if not wf:
|
||||||
return ToolResult(success=False, error=f"Workflow {workflowId} not found")
|
return _err(name, f"Workflow {workflowId} not found")
|
||||||
|
|
||||||
graph = dict(wf.get("graph", {}))
|
graph = dict(wf.get("graph", {}) or {})
|
||||||
nodes = [n for n in graph.get("nodes", []) if n.get("id") != nodeId]
|
nodes = [n for n in (graph.get("nodes", []) or []) if n.get("id") != nodeId]
|
||||||
connections = [
|
connections = [
|
||||||
c for c in graph.get("connections", [])
|
c for c in (graph.get("connections", []) or [])
|
||||||
if c.get("source") != nodeId and c.get("target") != nodeId
|
if c.get("source") != nodeId and c.get("target") != nodeId
|
||||||
]
|
]
|
||||||
graph["nodes"] = nodes
|
graph["nodes"] = nodes
|
||||||
graph["connections"] = connections
|
graph["connections"] = connections
|
||||||
|
|
||||||
iface.updateWorkflow(workflowId, {"graph": graph})
|
iface.updateWorkflow(workflowId, {"graph": graph})
|
||||||
return ToolResult(success=True, data={"nodeId": nodeId, "message": f"Node {nodeId} removed"})
|
return _ok(name, {"nodeId": nodeId, "message": f"Node {nodeId} removed"})
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.exception("removeNode failed: %s", e)
|
logger.exception("removeNode failed: %s", e)
|
||||||
return ToolResult(success=False, error=str(e))
|
return _err(name, str(e))
|
||||||
|
|
||||||
|
|
||||||
async def _connectNodes(params: Dict[str, Any], context: Any) -> ToolResult:
|
async def _connectNodes(params: Dict[str, Any], context: Any) -> ToolResult:
|
||||||
"""Connect two nodes in the workflow graph."""
|
"""Connect two nodes in the workflow graph."""
|
||||||
|
name = "connectNodes"
|
||||||
try:
|
try:
|
||||||
workflowId = params.get("workflowId")
|
workflowId, instanceId = _resolveIds(params, context)
|
||||||
instanceId = params.get("instanceId")
|
|
||||||
sourceId = params.get("sourceId")
|
sourceId = params.get("sourceId")
|
||||||
targetId = params.get("targetId")
|
targetId = params.get("targetId")
|
||||||
if not workflowId or not instanceId or not sourceId or not targetId:
|
if not workflowId or not instanceId or not sourceId or not targetId:
|
||||||
return ToolResult(success=False, error="workflowId, instanceId, sourceId, and targetId required")
|
return _err(name, "workflowId, instanceId, sourceId, and targetId required")
|
||||||
|
|
||||||
from modules.features.graphicalEditor.interfaceFeatureGraphicalEditor import getGraphicalEditorInterface
|
iface = _getInterface(context, instanceId)
|
||||||
user = getattr(context, "user", None)
|
|
||||||
mandateId = getattr(context, "mandateId", "") or ""
|
|
||||||
iface = getGraphicalEditorInterface(user, mandateId, instanceId)
|
|
||||||
wf = iface.getWorkflow(workflowId)
|
wf = iface.getWorkflow(workflowId)
|
||||||
if not wf:
|
if not wf:
|
||||||
return ToolResult(success=False, error=f"Workflow {workflowId} not found")
|
return _err(name, f"Workflow {workflowId} not found")
|
||||||
|
|
||||||
graph = dict(wf.get("graph", {}))
|
graph = dict(wf.get("graph", {}) or {})
|
||||||
connections = list(graph.get("connections", []))
|
connections = list(graph.get("connections", []) or [])
|
||||||
newConn = {
|
newConn = {
|
||||||
"source": sourceId,
|
"source": sourceId,
|
||||||
"target": targetId,
|
"target": targetId,
|
||||||
|
|
@ -160,93 +224,113 @@ async def _connectNodes(params: Dict[str, Any], context: Any) -> ToolResult:
|
||||||
graph["connections"] = connections
|
graph["connections"] = connections
|
||||||
|
|
||||||
iface.updateWorkflow(workflowId, {"graph": graph})
|
iface.updateWorkflow(workflowId, {"graph": graph})
|
||||||
return ToolResult(success=True, data={"connection": newConn, "message": f"Connected {sourceId} -> {targetId}"})
|
return _ok(name, {"connection": newConn, "message": f"Connected {sourceId} -> {targetId}"})
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.exception("connectNodes failed: %s", e)
|
logger.exception("connectNodes failed: %s", e)
|
||||||
return ToolResult(success=False, error=str(e))
|
return _err(name, str(e))
|
||||||
|
|
||||||
|
|
||||||
async def _setNodeParameter(params: Dict[str, Any], context: Any) -> ToolResult:
|
async def _setNodeParameter(params: Dict[str, Any], context: Any) -> ToolResult:
|
||||||
"""Set a parameter on a node."""
|
"""Set a parameter on a node."""
|
||||||
|
name = "setNodeParameter"
|
||||||
try:
|
try:
|
||||||
workflowId = params.get("workflowId")
|
workflowId, instanceId = _resolveIds(params, context)
|
||||||
instanceId = params.get("instanceId")
|
|
||||||
nodeId = params.get("nodeId")
|
nodeId = params.get("nodeId")
|
||||||
paramName = params.get("parameterName")
|
paramName = params.get("parameterName")
|
||||||
paramValue = params.get("parameterValue")
|
paramValue = params.get("parameterValue")
|
||||||
if not workflowId or not instanceId or not nodeId or not paramName:
|
if not workflowId or not instanceId or not nodeId or not paramName:
|
||||||
return ToolResult(success=False, error="workflowId, instanceId, nodeId, and parameterName required")
|
return _err(name, "workflowId, instanceId, nodeId, and parameterName required")
|
||||||
|
|
||||||
from modules.features.graphicalEditor.interfaceFeatureGraphicalEditor import getGraphicalEditorInterface
|
iface = _getInterface(context, instanceId)
|
||||||
user = getattr(context, "user", None)
|
|
||||||
mandateId = getattr(context, "mandateId", "") or ""
|
|
||||||
iface = getGraphicalEditorInterface(user, mandateId, instanceId)
|
|
||||||
wf = iface.getWorkflow(workflowId)
|
wf = iface.getWorkflow(workflowId)
|
||||||
if not wf:
|
if not wf:
|
||||||
return ToolResult(success=False, error=f"Workflow {workflowId} not found")
|
return _err(name, f"Workflow {workflowId} not found")
|
||||||
|
|
||||||
graph = dict(wf.get("graph", {}))
|
graph = dict(wf.get("graph", {}) or {})
|
||||||
nodes = list(graph.get("nodes", []))
|
nodes = list(graph.get("nodes", []) or [])
|
||||||
found = False
|
found = False
|
||||||
for n in nodes:
|
for n in nodes:
|
||||||
if n.get("id") == nodeId:
|
if n.get("id") == nodeId:
|
||||||
nodeParams = dict(n.get("parameters", {}))
|
nodeParams = dict(n.get("parameters", {}) or {})
|
||||||
nodeParams[paramName] = paramValue
|
nodeParams[paramName] = paramValue
|
||||||
n["parameters"] = nodeParams
|
n["parameters"] = nodeParams
|
||||||
found = True
|
found = True
|
||||||
break
|
break
|
||||||
|
|
||||||
if not found:
|
if not found:
|
||||||
return ToolResult(success=False, error=f"Node {nodeId} not found in graph")
|
return _err(name, f"Node {nodeId} not found in graph")
|
||||||
|
|
||||||
graph["nodes"] = nodes
|
graph["nodes"] = nodes
|
||||||
iface.updateWorkflow(workflowId, {"graph": graph})
|
iface.updateWorkflow(workflowId, {"graph": graph})
|
||||||
return ToolResult(success=True, data={"nodeId": nodeId, "parameter": paramName, "message": f"Parameter '{paramName}' set"})
|
return _ok(name, {
|
||||||
|
"nodeId": nodeId,
|
||||||
|
"parameter": paramName,
|
||||||
|
"message": f"Parameter '{paramName}' set",
|
||||||
|
})
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.exception("setNodeParameter failed: %s", e)
|
logger.exception("setNodeParameter failed: %s", e)
|
||||||
return ToolResult(success=False, error=str(e))
|
return _err(name, str(e))
|
||||||
|
|
||||||
|
|
||||||
|
def _coerceLabel(rawLabel: Any, fallback: str) -> str:
|
||||||
|
"""Normalize a node label which may be a string, dict {locale: str}, or other."""
|
||||||
|
if isinstance(rawLabel, str):
|
||||||
|
return rawLabel
|
||||||
|
if isinstance(rawLabel, dict):
|
||||||
|
for key in ("en", "de", "fr"):
|
||||||
|
value = rawLabel.get(key)
|
||||||
|
if isinstance(value, str) and value:
|
||||||
|
return value
|
||||||
|
for value in rawLabel.values():
|
||||||
|
if isinstance(value, str) and value:
|
||||||
|
return value
|
||||||
|
return fallback
|
||||||
|
|
||||||
|
|
||||||
async def _listAvailableNodeTypes(params: Dict[str, Any], context: Any) -> ToolResult:
|
async def _listAvailableNodeTypes(params: Dict[str, Any], context: Any) -> ToolResult:
|
||||||
"""List all available node types for the flow builder."""
|
"""List all available node types for the flow builder."""
|
||||||
|
name = "listAvailableNodeTypes"
|
||||||
try:
|
try:
|
||||||
from modules.features.graphicalEditor.nodeDefinitions import STATIC_NODE_TYPES
|
from modules.features.graphicalEditor.nodeDefinitions import STATIC_NODE_TYPES
|
||||||
nodeTypes = [
|
nodeTypes = []
|
||||||
{"id": n.get("id"), "category": n.get("category"), "label": n.get("label", {}).get("en", n.get("id"))}
|
for n in STATIC_NODE_TYPES:
|
||||||
for n in STATIC_NODE_TYPES
|
if not isinstance(n, dict):
|
||||||
]
|
continue
|
||||||
return ToolResult(success=True, data={"nodeTypes": nodeTypes, "count": len(nodeTypes)})
|
nodeId = n.get("id") or ""
|
||||||
|
nodeTypes.append({
|
||||||
|
"id": nodeId,
|
||||||
|
"category": n.get("category"),
|
||||||
|
"label": _coerceLabel(n.get("label"), nodeId),
|
||||||
|
})
|
||||||
|
return _ok(name, {"nodeTypes": nodeTypes, "count": len(nodeTypes)})
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.exception("listAvailableNodeTypes failed: %s", e)
|
logger.exception("listAvailableNodeTypes failed: %s", e)
|
||||||
return ToolResult(success=False, error=str(e))
|
return _err(name, str(e))
|
||||||
|
|
||||||
|
|
||||||
async def _validateGraph(params: Dict[str, Any], context: Any) -> ToolResult:
|
async def _validateGraph(params: Dict[str, Any], context: Any) -> ToolResult:
|
||||||
"""Validate a workflow graph for common issues."""
|
"""Validate a workflow graph for common issues."""
|
||||||
|
name = "validateGraph"
|
||||||
try:
|
try:
|
||||||
workflowId = params.get("workflowId")
|
workflowId, instanceId = _resolveIds(params, context)
|
||||||
instanceId = params.get("instanceId")
|
|
||||||
if not workflowId or not instanceId:
|
if not workflowId or not instanceId:
|
||||||
return ToolResult(success=False, error="workflowId and instanceId required")
|
return _err(name, "workflowId and instanceId required")
|
||||||
|
|
||||||
from modules.features.graphicalEditor.interfaceFeatureGraphicalEditor import getGraphicalEditorInterface
|
iface = _getInterface(context, instanceId)
|
||||||
user = getattr(context, "user", None)
|
|
||||||
mandateId = getattr(context, "mandateId", "") or ""
|
|
||||||
iface = getGraphicalEditorInterface(user, mandateId, instanceId)
|
|
||||||
wf = iface.getWorkflow(workflowId)
|
wf = iface.getWorkflow(workflowId)
|
||||||
if not wf:
|
if not wf:
|
||||||
return ToolResult(success=False, error=f"Workflow {workflowId} not found")
|
return _err(name, f"Workflow {workflowId} not found")
|
||||||
|
|
||||||
graph = wf.get("graph", {})
|
graph = wf.get("graph", {}) or {}
|
||||||
nodes = graph.get("nodes", [])
|
nodes = graph.get("nodes", []) or []
|
||||||
connections = graph.get("connections", [])
|
connections = graph.get("connections", []) or []
|
||||||
issues: List[str] = []
|
issues: List[str] = []
|
||||||
|
|
||||||
nodeIds = {n.get("id") for n in nodes}
|
nodeIds = {n.get("id") for n in nodes}
|
||||||
if not nodes:
|
if not nodes:
|
||||||
issues.append("Graph has no nodes")
|
issues.append("Graph has no nodes")
|
||||||
|
|
||||||
hasTrigger = any(n.get("type", "").startswith("trigger.") for n in nodes)
|
hasTrigger = any((n.get("type") or "").startswith("trigger.") for n in nodes)
|
||||||
if not hasTrigger:
|
if not hasTrigger:
|
||||||
issues.append("No trigger node found")
|
issues.append("No trigger node found")
|
||||||
|
|
||||||
|
|
@ -260,37 +344,34 @@ async def _validateGraph(params: Dict[str, Any], context: Any) -> ToolResult:
|
||||||
for c in connections:
|
for c in connections:
|
||||||
connectedNodes.add(c.get("source"))
|
connectedNodes.add(c.get("source"))
|
||||||
connectedNodes.add(c.get("target"))
|
connectedNodes.add(c.get("target"))
|
||||||
orphans = [n.get("id") for n in nodes if n.get("id") not in connectedNodes and not n.get("type", "").startswith("trigger.")]
|
orphans = [
|
||||||
|
n.get("id") for n in nodes
|
||||||
|
if n.get("id") not in connectedNodes and not (n.get("type") or "").startswith("trigger.")
|
||||||
|
]
|
||||||
if orphans:
|
if orphans:
|
||||||
issues.append(f"Orphan nodes (not connected): {', '.join(orphans)}")
|
issues.append(f"Orphan nodes (not connected): {', '.join(orphans)}")
|
||||||
|
|
||||||
return ToolResult(
|
return _ok(name, {
|
||||||
success=True,
|
|
||||||
data={
|
|
||||||
"valid": len(issues) == 0,
|
"valid": len(issues) == 0,
|
||||||
"issues": issues,
|
"issues": issues,
|
||||||
"nodeCount": len(nodes),
|
"nodeCount": len(nodes),
|
||||||
"connectionCount": len(connections),
|
"connectionCount": len(connections),
|
||||||
},
|
})
|
||||||
)
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.exception("validateGraph failed: %s", e)
|
logger.exception("validateGraph failed: %s", e)
|
||||||
return ToolResult(success=False, error=str(e))
|
return _err(name, str(e))
|
||||||
|
|
||||||
|
|
||||||
async def _listWorkflowHistory(params: Dict[str, Any], context: Any) -> ToolResult:
|
async def _listWorkflowHistory(params: Dict[str, Any], context: Any) -> ToolResult:
|
||||||
"""List versions (history) for a workflow."""
|
"""List versions (history) for a workflow."""
|
||||||
|
name = "listWorkflowHistory"
|
||||||
try:
|
try:
|
||||||
workflowId = params.get("workflowId", "")
|
workflowId, instanceId = _resolveIds(params, context)
|
||||||
instanceId = params.get("instanceId", "")
|
if not workflowId or not instanceId:
|
||||||
from modules.features.graphicalEditor.interfaceFeatureGraphicalEditor import getGraphicalEditorInterface
|
return _err(name, "workflowId and instanceId required")
|
||||||
user = getattr(context, "user", None)
|
iface = _getInterface(context, instanceId)
|
||||||
mandateId = getattr(context, "mandateId", "") or ""
|
versions = iface.getVersions(workflowId) or []
|
||||||
iface = getGraphicalEditorInterface(user, mandateId, instanceId)
|
return _ok(name, {
|
||||||
versions = iface.getVersions(workflowId)
|
|
||||||
return ToolResult(
|
|
||||||
success=True,
|
|
||||||
data={
|
|
||||||
"workflowId": workflowId,
|
"workflowId": workflowId,
|
||||||
"versions": [
|
"versions": [
|
||||||
{
|
{
|
||||||
|
|
@ -302,22 +383,20 @@ async def _listWorkflowHistory(params: Dict[str, Any], context: Any) -> ToolResu
|
||||||
}
|
}
|
||||||
for v in versions
|
for v in versions
|
||||||
],
|
],
|
||||||
},
|
})
|
||||||
)
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.exception("listWorkflowHistory failed: %s", e)
|
logger.exception("listWorkflowHistory failed: %s", e)
|
||||||
return ToolResult(success=False, error=str(e))
|
return _err(name, str(e))
|
||||||
|
|
||||||
|
|
||||||
async def _readWorkflowMessages(params: Dict[str, Any], context: Any) -> ToolResult:
|
async def _readWorkflowMessages(params: Dict[str, Any], context: Any) -> ToolResult:
|
||||||
"""Read recent run logs/messages for a workflow."""
|
"""Read recent run logs/messages for a workflow."""
|
||||||
|
name = "readWorkflowMessages"
|
||||||
try:
|
try:
|
||||||
workflowId = params.get("workflowId", "")
|
workflowId, instanceId = _resolveIds(params, context)
|
||||||
instanceId = params.get("instanceId", "")
|
if not workflowId or not instanceId:
|
||||||
from modules.features.graphicalEditor.interfaceFeatureGraphicalEditor import getGraphicalEditorInterface
|
return _err(name, "workflowId and instanceId required")
|
||||||
user = getattr(context, "user", None)
|
iface = _getInterface(context, instanceId)
|
||||||
mandateId = getattr(context, "mandateId", "") or ""
|
|
||||||
iface = getGraphicalEditorInterface(user, mandateId, instanceId)
|
|
||||||
from modules.features.graphicalEditor.datamodelFeatureGraphicalEditor import AutoRun
|
from modules.features.graphicalEditor.datamodelFeatureGraphicalEditor import AutoRun
|
||||||
runs = iface.db.getRecordset(AutoRun, recordFilter={"workflowId": workflowId}) or []
|
runs = iface.db.getRecordset(AutoRun, recordFilter={"workflowId": workflowId}) or []
|
||||||
runSummaries = []
|
runSummaries = []
|
||||||
|
|
@ -329,104 +408,106 @@ async def _readWorkflowMessages(params: Dict[str, Any], context: Any) -> ToolRes
|
||||||
"completedAt": r.get("completedAt"),
|
"completedAt": r.get("completedAt"),
|
||||||
"error": r.get("error"),
|
"error": r.get("error"),
|
||||||
})
|
})
|
||||||
return ToolResult(
|
return _ok(name, {"workflowId": workflowId, "recentRuns": runSummaries})
|
||||||
success=True,
|
|
||||||
data={"workflowId": workflowId, "recentRuns": runSummaries},
|
|
||||||
)
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.exception("readWorkflowMessages failed: %s", e)
|
logger.exception("readWorkflowMessages failed: %s", e)
|
||||||
return ToolResult(success=False, error=str(e))
|
return _err(name, str(e))
|
||||||
|
|
||||||
|
|
||||||
def getWorkflowToolDefinitions() -> List[Dict[str, Any]]:
|
def getWorkflowToolDefinitions() -> List[Dict[str, Any]]:
|
||||||
"""Return tool definitions for registration in the ToolRegistry."""
|
"""Return tool definitions for registration in the ToolRegistry.
|
||||||
|
|
||||||
|
Note: ``workflowId`` and ``instanceId`` are NOT marked ``required`` —
|
||||||
|
they are auto-injected from the agent context by ``_resolveIds``. The
|
||||||
|
model may still pass them explicitly (e.g. to target a different
|
||||||
|
workflow) but doesn't have to repeat them on every call.
|
||||||
|
"""
|
||||||
|
_idFields = {
|
||||||
|
"workflowId": {"type": "string", "description": "Workflow ID (defaults to the current editor workflow)"},
|
||||||
|
"instanceId": {"type": "string", "description": "Feature instance ID (defaults to the current editor instance)"},
|
||||||
|
}
|
||||||
return [
|
return [
|
||||||
{
|
{
|
||||||
"name": "readWorkflowGraph",
|
"name": "readWorkflowGraph",
|
||||||
"handler": _readWorkflowGraph,
|
"handler": _readWorkflowGraph,
|
||||||
"description": "Read the current workflow graph (nodes and connections)",
|
"description": "Read the current workflow graph (nodes and connections). Always call this first to understand the current state before making changes.",
|
||||||
"parameters": {
|
"parameters": {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"properties": {
|
"properties": {**_idFields},
|
||||||
"workflowId": {"type": "string", "description": "Workflow ID"},
|
"required": [],
|
||||||
"instanceId": {"type": "string", "description": "Feature instance ID"},
|
|
||||||
},
|
|
||||||
"required": ["workflowId", "instanceId"],
|
|
||||||
},
|
},
|
||||||
|
"readOnly": True,
|
||||||
"toolSet": TOOLBOX_ID,
|
"toolSet": TOOLBOX_ID,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "addNode",
|
"name": "addNode",
|
||||||
"handler": _addNode,
|
"handler": _addNode,
|
||||||
"description": "Add a node to the workflow graph",
|
"description": "Add a node to the workflow graph.",
|
||||||
"parameters": {
|
"parameters": {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"properties": {
|
"properties": {
|
||||||
"workflowId": {"type": "string"},
|
**_idFields,
|
||||||
"instanceId": {"type": "string"},
|
"nodeType": {"type": "string", "description": "Node type id (e.g. ai.chat, email.send) — use listAvailableNodeTypes to discover"},
|
||||||
"nodeType": {"type": "string", "description": "Node type (e.g. ai.chat, email.send)"},
|
|
||||||
"title": {"type": "string", "description": "Human-readable title"},
|
"title": {"type": "string", "description": "Human-readable title"},
|
||||||
"parameters": {"type": "object", "description": "Node parameters"},
|
"parameters": {"type": "object", "description": "Node parameters"},
|
||||||
"position": {"type": "object", "description": "Canvas position {x, y}"},
|
"position": {"type": "object", "description": "Canvas position {x, y}"},
|
||||||
|
"nodeId": {"type": "string", "description": "Optional explicit node id"},
|
||||||
},
|
},
|
||||||
"required": ["workflowId", "instanceId", "nodeType"],
|
"required": ["nodeType"],
|
||||||
},
|
},
|
||||||
"toolSet": TOOLBOX_ID,
|
"toolSet": TOOLBOX_ID,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "removeNode",
|
"name": "removeNode",
|
||||||
"handler": _removeNode,
|
"handler": _removeNode,
|
||||||
"description": "Remove a node and its connections from the graph",
|
"description": "Remove a node and its connections from the graph.",
|
||||||
"parameters": {
|
"parameters": {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"properties": {
|
"properties": {
|
||||||
"workflowId": {"type": "string"},
|
**_idFields,
|
||||||
"instanceId": {"type": "string"},
|
|
||||||
"nodeId": {"type": "string", "description": "ID of the node to remove"},
|
"nodeId": {"type": "string", "description": "ID of the node to remove"},
|
||||||
},
|
},
|
||||||
"required": ["workflowId", "instanceId", "nodeId"],
|
"required": ["nodeId"],
|
||||||
},
|
},
|
||||||
"toolSet": TOOLBOX_ID,
|
"toolSet": TOOLBOX_ID,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "connectNodes",
|
"name": "connectNodes",
|
||||||
"handler": _connectNodes,
|
"handler": _connectNodes,
|
||||||
"description": "Connect two nodes in the graph",
|
"description": "Connect two nodes in the graph (source -> target).",
|
||||||
"parameters": {
|
"parameters": {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"properties": {
|
"properties": {
|
||||||
"workflowId": {"type": "string"},
|
**_idFields,
|
||||||
"instanceId": {"type": "string"},
|
|
||||||
"sourceId": {"type": "string"},
|
"sourceId": {"type": "string"},
|
||||||
"targetId": {"type": "string"},
|
"targetId": {"type": "string"},
|
||||||
"sourceOutput": {"type": "integer", "default": 0},
|
"sourceOutput": {"type": "integer", "default": 0},
|
||||||
"targetInput": {"type": "integer", "default": 0},
|
"targetInput": {"type": "integer", "default": 0},
|
||||||
},
|
},
|
||||||
"required": ["workflowId", "instanceId", "sourceId", "targetId"],
|
"required": ["sourceId", "targetId"],
|
||||||
},
|
},
|
||||||
"toolSet": TOOLBOX_ID,
|
"toolSet": TOOLBOX_ID,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "setNodeParameter",
|
"name": "setNodeParameter",
|
||||||
"handler": _setNodeParameter,
|
"handler": _setNodeParameter,
|
||||||
"description": "Set a parameter on a node",
|
"description": "Set a single parameter on a node.",
|
||||||
"parameters": {
|
"parameters": {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"properties": {
|
"properties": {
|
||||||
"workflowId": {"type": "string"},
|
**_idFields,
|
||||||
"instanceId": {"type": "string"},
|
|
||||||
"nodeId": {"type": "string"},
|
"nodeId": {"type": "string"},
|
||||||
"parameterName": {"type": "string"},
|
"parameterName": {"type": "string"},
|
||||||
"parameterValue": {"description": "Value to set (any type)"},
|
"parameterValue": {"description": "Value to set (any type)"},
|
||||||
},
|
},
|
||||||
"required": ["workflowId", "instanceId", "nodeId", "parameterName", "parameterValue"],
|
"required": ["nodeId", "parameterName", "parameterValue"],
|
||||||
},
|
},
|
||||||
"toolSet": TOOLBOX_ID,
|
"toolSet": TOOLBOX_ID,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "listAvailableNodeTypes",
|
"name": "listAvailableNodeTypes",
|
||||||
"handler": _listAvailableNodeTypes,
|
"handler": _listAvailableNodeTypes,
|
||||||
"description": "List all available node types for the flow builder",
|
"description": "List all available node types for the flow builder. Call this once to discover ids before using addNode.",
|
||||||
"parameters": {"type": "object", "properties": {}},
|
"parameters": {"type": "object", "properties": {}},
|
||||||
"readOnly": True,
|
"readOnly": True,
|
||||||
"toolSet": TOOLBOX_ID,
|
"toolSet": TOOLBOX_ID,
|
||||||
|
|
@ -434,14 +515,11 @@ def getWorkflowToolDefinitions() -> List[Dict[str, Any]]:
|
||||||
{
|
{
|
||||||
"name": "validateGraph",
|
"name": "validateGraph",
|
||||||
"handler": _validateGraph,
|
"handler": _validateGraph,
|
||||||
"description": "Validate a workflow graph for common issues",
|
"description": "Validate a workflow graph for common issues (missing trigger, dangling connections, orphans).",
|
||||||
"parameters": {
|
"parameters": {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"properties": {
|
"properties": {**_idFields},
|
||||||
"workflowId": {"type": "string"},
|
"required": [],
|
||||||
"instanceId": {"type": "string"},
|
|
||||||
},
|
|
||||||
"required": ["workflowId", "instanceId"],
|
|
||||||
},
|
},
|
||||||
"readOnly": True,
|
"readOnly": True,
|
||||||
"toolSet": TOOLBOX_ID,
|
"toolSet": TOOLBOX_ID,
|
||||||
|
|
@ -449,14 +527,11 @@ def getWorkflowToolDefinitions() -> List[Dict[str, Any]]:
|
||||||
{
|
{
|
||||||
"name": "listWorkflowHistory",
|
"name": "listWorkflowHistory",
|
||||||
"handler": _listWorkflowHistory,
|
"handler": _listWorkflowHistory,
|
||||||
"description": "List version history for a workflow (AutoVersion entries)",
|
"description": "List version history for a workflow (AutoVersion entries).",
|
||||||
"parameters": {
|
"parameters": {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"properties": {
|
"properties": {**_idFields},
|
||||||
"workflowId": {"type": "string"},
|
"required": [],
|
||||||
"instanceId": {"type": "string"},
|
|
||||||
},
|
|
||||||
"required": ["workflowId", "instanceId"],
|
|
||||||
},
|
},
|
||||||
"readOnly": True,
|
"readOnly": True,
|
||||||
"toolSet": TOOLBOX_ID,
|
"toolSet": TOOLBOX_ID,
|
||||||
|
|
@ -464,14 +539,11 @@ def getWorkflowToolDefinitions() -> List[Dict[str, Any]]:
|
||||||
{
|
{
|
||||||
"name": "readWorkflowMessages",
|
"name": "readWorkflowMessages",
|
||||||
"handler": _readWorkflowMessages,
|
"handler": _readWorkflowMessages,
|
||||||
"description": "Read recent run logs and status for a workflow",
|
"description": "Read recent run logs and status for a workflow.",
|
||||||
"parameters": {
|
"parameters": {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"properties": {
|
"properties": {**_idFields},
|
||||||
"workflowId": {"type": "string"},
|
"required": [],
|
||||||
"instanceId": {"type": "string"},
|
|
||||||
},
|
|
||||||
"required": ["workflowId", "instanceId"],
|
|
||||||
},
|
},
|
||||||
"readOnly": True,
|
"readOnly": True,
|
||||||
"toolSet": TOOLBOX_ID,
|
"toolSet": TOOLBOX_ID,
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue