gateway/modules/serviceCenter/services/serviceAgent/actionToolAdapter.py
2026-04-25 01:13:01 +02:00

241 lines
8.7 KiB
Python

# Copyright (c) 2025 Patrick Motsch
# All rights reserved.
"""ActionToolAdapter: wraps existing workflow actions (dynamicMode=True) as agent tools."""
import logging
from typing import Dict, Any, List
from modules.serviceCenter.services.serviceAgent.datamodelAgent import (
ToolDefinition, ToolResult
)
from modules.serviceCenter.services.serviceAgent.toolRegistry import ToolRegistry
logger = logging.getLogger(__name__)
class ActionToolAdapter:
"""Wraps existing Workflow-Actions as Agent-Tools.
Iterates over discovered methods, finds actions with dynamicMode=True,
and registers them in the ToolRegistry with a compound name (method.action).
"""
def __init__(self, actionExecutor):
self._actionExecutor = actionExecutor
self._registeredTools: List[str] = []
def registerAll(self, toolRegistry: ToolRegistry):
"""Discover and register all dynamicMode actions as agent tools."""
from modules.workflows.processing.shared.methodDiscovery import methods
registered = 0
for methodName, methodInfo in methods.items():
if not methodName[0].isupper():
continue
shortName = methodName.replace("Method", "").lower()
methodInstance = methodInfo["instance"]
for actionName, actionInfo in methodInfo["actions"].items():
actionDef = methodInstance._actions.get(actionName)
if not actionDef or not getattr(actionDef, "dynamicMode", False):
continue
compoundName = f"{shortName}_{actionName}"
toolDef = _buildToolDefinition(compoundName, actionDef, actionInfo)
handler = _createDispatchHandler(self._actionExecutor, shortName, actionName)
toolRegistry.registerFromDefinition(toolDef, handler)
self._registeredTools.append(compoundName)
registered += 1
logger.info(f"ActionToolAdapter: registered {registered} tools from workflow actions")
@property
def registeredTools(self) -> List[str]:
"""Names of all tools registered by this adapter."""
return list(self._registeredTools)
def _buildToolDefinition(compoundName: str, actionDef, actionInfo: Dict[str, Any]) -> ToolDefinition:
"""Build a ToolDefinition from a WorkflowActionDefinition."""
parameters = _convertParameterSchema(actionInfo.get("parameters", {}))
return ToolDefinition(
name=compoundName,
description=actionDef.description or actionInfo.get("description", ""),
parameters=parameters,
readOnly=False
)
def _convertParameterSchema(actionParams: Dict[str, Any]) -> Dict[str, Any]:
"""Convert workflow action parameter schema to JSON Schema for tool definitions.
Schicht-3 Adapter (typed): looks up each parameter's `type` against the
PORT_TYPE_CATALOG and produces a strict JSON Schema fragment.
Falls back to a generic string schema only when the type is fully unknown
(which should never happen after Phase 2's signature validator).
"""
properties: Dict[str, Any] = {}
required: List[str] = []
for paramName, paramInfo in actionParams.items():
if not isinstance(paramInfo, dict):
properties[paramName] = {"type": "string", "description": ""}
continue
paramType = paramInfo.get("type", "str")
paramDesc = paramInfo.get("description", "") or ""
paramRequired = bool(paramInfo.get("required", False))
prop = _catalogTypeToJsonSchema(paramType)
if paramDesc:
prop["description"] = paramDesc
properties[paramName] = prop
if paramRequired:
required.append(paramName)
return {
"type": "object",
"properties": properties,
"required": required,
}
# Primitive Python type strings → JSON Schema scalar types.
_PRIMITIVE_JSON_TYPE: Dict[str, str] = {
"str": "string",
"int": "integer",
"float": "number",
"bool": "boolean",
}
def _catalogTypeToJsonSchema(typeStr: str, _depth: int = 0) -> Dict[str, Any]:
"""Recursively convert a PORT_TYPE_CATALOG type reference into a JSON Schema fragment.
Supports:
- Primitives (str/int/bool/float/Any)
- Catalog object schemas (recursively expanded with properties/required)
- List[X] (array with typed items)
- Dict[K, V] (object with typed additionalProperties)
`_depth` guards against pathological recursion in case of a cyclic catalog.
"""
from modules.features.graphicalEditor.portTypes import (
PORT_TYPE_CATALOG,
PRIMITIVE_TYPES,
)
if _depth > 6:
return {"type": "object", "description": "(max-depth)"}
if not typeStr or not isinstance(typeStr, str):
return {"type": "string"}
typeStr = typeStr.strip()
if typeStr in _PRIMITIVE_JSON_TYPE:
return {"type": _PRIMITIVE_JSON_TYPE[typeStr]}
if typeStr == "Any":
return {}
if typeStr.startswith("List[") and typeStr.endswith("]"):
inner = typeStr[5:-1].strip()
return {"type": "array", "items": _catalogTypeToJsonSchema(inner, _depth + 1)}
if typeStr.startswith("Dict[") and typeStr.endswith("]"):
inner = typeStr[5:-1].strip()
valueType = "Any"
parts = [p.strip() for p in inner.split(",", 1)]
if len(parts) == 2:
valueType = parts[1]
return {
"type": "object",
"additionalProperties": _catalogTypeToJsonSchema(valueType, _depth + 1),
}
schema = PORT_TYPE_CATALOG.get(typeStr)
if schema is not None:
props: Dict[str, Any] = {}
required: List[str] = []
for f in schema.fields:
fragment = _catalogTypeToJsonSchema(f.type, _depth + 1)
if f.description:
fragment["description"] = f.description
if f.enumValues:
fragment["enum"] = list(f.enumValues)
props[f.name] = fragment
if f.required:
required.append(f.name)
out: Dict[str, Any] = {
"type": "object",
"properties": props,
"description": f"PORT_TYPE_CATALOG schema '{schema.name}'",
}
if required:
out["required"] = required
return out
# Lowercase 'list' / 'dict' aliases (legacy, should be eradicated by Phase 2 validator)
if typeStr in PRIMITIVE_TYPES and typeStr in {"List", "Dict"}:
return {"type": "array" if typeStr == "List" else "object"}
return {"type": "string", "description": f"unknown type '{typeStr}' (defaulted to string)"}
def _createDispatchHandler(actionExecutor, methodName: str, actionName: str):
"""Create an async handler that dispatches to the ActionExecutor."""
async def _handler(args: Dict[str, Any], context: Dict[str, Any]) -> ToolResult:
try:
if context:
if "featureInstanceId" not in args and context.get("featureInstanceId"):
args["featureInstanceId"] = context["featureInstanceId"]
if "mandateId" not in args and context.get("mandateId"):
args["mandateId"] = context["mandateId"]
result = await actionExecutor.executeAction(methodName, actionName, args)
data = _formatActionResult(result)
return ToolResult(
toolCallId="",
toolName=f"{methodName}_{actionName}",
success=result.success,
data=data,
error=result.error
)
except Exception as e:
logger.error(f"ActionToolAdapter dispatch failed for {methodName}_{actionName}: {e}")
return ToolResult(
toolCallId="",
toolName=f"{methodName}_{actionName}",
success=False,
error=str(e)
)
return _handler
def _formatActionResult(result) -> str:
"""Format an ActionResult into a text representation for the agent."""
parts = []
if result.resultLabel:
parts.append(f"Result: {result.resultLabel}")
if result.error:
parts.append(f"Error: {result.error}")
if result.documents:
parts.append(f"Documents ({len(result.documents)}):")
for doc in result.documents:
docName = getattr(doc, "documentName", "unnamed")
docType = getattr(doc, "mimeType", "unknown")
parts.append(f" - {docName} ({docType})")
docData = getattr(doc, "documentData", None)
if docData and isinstance(docData, str) and len(docData) < 2000:
parts.append(f" Content: {docData[:2000]}")
if not parts:
parts.append("Action completed successfully." if result.success else "Action failed.")
return "\n".join(parts)