xml-pipeline/bloxserver/domain/nodes.py
dullfig 9ba77b843d Add Flow domain model for BloxServer
Domain model bridges frontend canvas, database, and xml-pipeline:

- nodes.py: AgentNode, ToolNode, GatewayNode with serialization
  - Built-in tool mappings (calculate, fetch, shell, etc.)
  - Agent config (prompt, model, temperature)
  - Gateway config (federation + REST API)

- edges.py: Edge connections with conditions
  - Auto-compute peers from edges
  - Cycle detection, entry/exit node finding

- triggers.py: Webhook, Schedule, Manual, Event triggers
  - Config dataclasses for each type
  - Factory functions for common patterns

- flow.py: Main Flow class aggregating all components
  - to_organism_yaml(): Generate xml-pipeline config
  - from_canvas_json() / to_canvas_json(): React Flow compat
  - validate(): Check for errors before execution
  - to_db_dict(): Database serialization

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-22 22:15:51 -08:00

412 lines
14 KiB
Python

"""
Node types for BloxServer flows.
Nodes are the building blocks of flows:
- AgentNode: LLM-powered agents with prompts and reasoning
- ToolNode: Built-in tools (calculate, fetch, shell, etc.)
- GatewayNode: External APIs or federated organisms
"""
from __future__ import annotations
from dataclasses import dataclass, field
from enum import Enum
from typing import Any
from uuid import UUID, uuid4
class NodeType(str, Enum):
"""Types of nodes in a flow."""
AGENT = "agent"
TOOL = "tool"
GATEWAY = "gateway"
# =============================================================================
# Built-in Tool Types
# =============================================================================
class ToolType(str, Enum):
"""Built-in tool types available in BloxServer."""
CALCULATE = "calculate"
FETCH = "fetch"
READ_FILE = "read_file"
WRITE_FILE = "write_file"
LIST_DIR = "list_dir"
SHELL = "shell"
WEB_SEARCH = "web_search"
KEY_VALUE = "key_value"
SEND_EMAIL = "send_email"
WEBHOOK = "webhook"
LIBRARIAN = "librarian"
CUSTOM = "custom"
# =============================================================================
# Base Node
# =============================================================================
@dataclass
class Node:
"""Base class for all node types."""
id: UUID
name: str
description: str
node_type: NodeType
# Canvas position (for frontend rendering)
position_x: float = 0.0
position_y: float = 0.0
# Metadata for UI
color: str | None = None
icon: str | None = None
def to_dict(self) -> dict[str, Any]:
"""Convert to dictionary for JSON serialization."""
return {
"id": str(self.id),
"name": self.name,
"description": self.description,
"nodeType": self.node_type.value,
"position": {"x": self.position_x, "y": self.position_y},
"color": self.color,
"icon": self.icon,
}
@classmethod
def from_dict(cls, data: dict[str, Any]) -> Node:
"""Create node from dictionary. Dispatches to appropriate subclass."""
node_type = NodeType(data.get("nodeType", data.get("node_type")))
if node_type == NodeType.AGENT:
return AgentNode.from_dict(data)
elif node_type == NodeType.TOOL:
return ToolNode.from_dict(data)
elif node_type == NodeType.GATEWAY:
return GatewayNode.from_dict(data)
else:
raise ValueError(f"Unknown node type: {node_type}")
# =============================================================================
# Agent Node
# =============================================================================
@dataclass
class AgentNode(Node):
"""
LLM-powered agent node.
Agents have prompts, can reason, and communicate with other nodes.
They are the "brains" of a flow.
"""
node_type: NodeType = field(default=NodeType.AGENT, init=False)
# Agent configuration
prompt: str = ""
model: str | None = None # None = use default from org settings
# Advanced settings
temperature: float = 0.7
max_tokens: int | None = None
system_prompt_append: str | None = None # Extra instructions
# Handler paths (auto-generated if not specified)
handler_path: str | None = None
payload_class_path: str | None = None
def to_dict(self) -> dict[str, Any]:
base = super().to_dict()
base.update({
"prompt": self.prompt,
"model": self.model,
"temperature": self.temperature,
"maxTokens": self.max_tokens,
"systemPromptAppend": self.system_prompt_append,
"handlerPath": self.handler_path,
"payloadClassPath": self.payload_class_path,
})
return base
@classmethod
def from_dict(cls, data: dict[str, Any]) -> AgentNode:
position = data.get("position", {})
return cls(
id=UUID(data["id"]) if isinstance(data.get("id"), str) else data.get("id", uuid4()),
name=data["name"],
description=data.get("description", ""),
position_x=position.get("x", 0.0),
position_y=position.get("y", 0.0),
color=data.get("color"),
icon=data.get("icon"),
prompt=data.get("prompt", ""),
model=data.get("model"),
temperature=data.get("temperature", 0.7),
max_tokens=data.get("maxTokens") or data.get("max_tokens"),
system_prompt_append=data.get("systemPromptAppend") or data.get("system_prompt_append"),
handler_path=data.get("handlerPath") or data.get("handler_path"),
payload_class_path=data.get("payloadClassPath") or data.get("payload_class_path"),
)
def to_listener_config(self, peers: list[str]) -> dict[str, Any]:
"""
Convert to organism.yaml listener configuration.
Args:
peers: List of peer names derived from outgoing edges.
"""
config: dict[str, Any] = {
"name": self.name,
"description": self.description,
"agent": True,
"prompt": self.prompt,
}
if peers:
config["peers"] = peers
if self.handler_path:
config["handler"] = self.handler_path
if self.payload_class_path:
config["payload_class"] = self.payload_class_path
# LLM settings as metadata (for future use)
if self.model:
config["model"] = self.model
if self.temperature != 0.7:
config["temperature"] = self.temperature
if self.max_tokens:
config["max_tokens"] = self.max_tokens
return config
# =============================================================================
# Tool Node
# =============================================================================
# Tool handler/payload mappings for built-in tools
BUILTIN_TOOLS: dict[ToolType, dict[str, str]] = {
ToolType.CALCULATE: {
"handler": "xml_pipeline.tools.calculate.handle_calculate",
"payload_class": "xml_pipeline.tools.calculate.CalculatePayload",
},
ToolType.FETCH: {
"handler": "xml_pipeline.tools.fetch.handle_fetch",
"payload_class": "xml_pipeline.tools.fetch.FetchPayload",
},
ToolType.READ_FILE: {
"handler": "xml_pipeline.tools.files.handle_read",
"payload_class": "xml_pipeline.tools.files.ReadFilePayload",
},
ToolType.WRITE_FILE: {
"handler": "xml_pipeline.tools.files.handle_write",
"payload_class": "xml_pipeline.tools.files.WriteFilePayload",
},
ToolType.LIST_DIR: {
"handler": "xml_pipeline.tools.files.handle_list",
"payload_class": "xml_pipeline.tools.files.ListDirPayload",
},
ToolType.SHELL: {
"handler": "xml_pipeline.tools.shell.handle_shell",
"payload_class": "xml_pipeline.tools.shell.ShellPayload",
},
ToolType.WEB_SEARCH: {
"handler": "xml_pipeline.tools.search.handle_search",
"payload_class": "xml_pipeline.tools.search.SearchPayload",
},
ToolType.KEY_VALUE: {
"handler": "xml_pipeline.tools.keyvalue.handle_keyvalue",
"payload_class": "xml_pipeline.tools.keyvalue.KeyValuePayload",
},
ToolType.LIBRARIAN: {
"handler": "xml_pipeline.tools.librarian.handle_librarian",
"payload_class": "xml_pipeline.tools.librarian.LibrarianPayload",
},
}
@dataclass
class ToolNode(Node):
"""
Built-in or custom tool node.
Tools are stateless functions that perform specific operations
like calculations, HTTP requests, file operations, etc.
"""
node_type: NodeType = field(default=NodeType.TOOL, init=False)
# Tool configuration
tool_type: ToolType = ToolType.CUSTOM
config: dict[str, Any] = field(default_factory=dict)
# Custom tool paths (only for CUSTOM type)
handler_path: str | None = None
payload_class_path: str | None = None
def to_dict(self) -> dict[str, Any]:
base = super().to_dict()
base.update({
"toolType": self.tool_type.value,
"config": self.config,
"handlerPath": self.handler_path,
"payloadClassPath": self.payload_class_path,
})
return base
@classmethod
def from_dict(cls, data: dict[str, Any]) -> ToolNode:
position = data.get("position", {})
tool_type_str = data.get("toolType") or data.get("tool_type", "custom")
return cls(
id=UUID(data["id"]) if isinstance(data.get("id"), str) else data.get("id", uuid4()),
name=data["name"],
description=data.get("description", ""),
position_x=position.get("x", 0.0),
position_y=position.get("y", 0.0),
color=data.get("color"),
icon=data.get("icon"),
tool_type=ToolType(tool_type_str),
config=data.get("config", {}),
handler_path=data.get("handlerPath") or data.get("handler_path"),
payload_class_path=data.get("payloadClassPath") or data.get("payload_class_path"),
)
def to_listener_config(self) -> dict[str, Any]:
"""Convert to organism.yaml listener configuration."""
# Get handler/payload from built-in mapping or custom paths
if self.tool_type in BUILTIN_TOOLS:
tool_info = BUILTIN_TOOLS[self.tool_type]
handler = tool_info["handler"]
payload_class = tool_info["payload_class"]
else:
handler = self.handler_path
payload_class = self.payload_class_path
if not handler or not payload_class:
raise ValueError(
f"Custom tool '{self.name}' requires handler_path and payload_class_path"
)
config: dict[str, Any] = {
"name": self.name,
"description": self.description,
"handler": handler,
"payload_class": payload_class,
}
# Include tool-specific config if present
if self.config:
config["tool_config"] = self.config
return config
# =============================================================================
# Gateway Node
# =============================================================================
@dataclass
class GatewayNode(Node):
"""
External API or federated organism gateway.
Gateways connect flows to:
- External REST APIs
- Federated xml-pipeline organisms
- Third-party services (Slack, Discord, etc.)
"""
node_type: NodeType = field(default=NodeType.GATEWAY, init=False)
# Federation (other xml-pipeline organisms)
remote_url: str | None = None
trusted_identity: str | None = None # Path to public key
# REST API gateway
api_endpoint: str | None = None
api_method: str = "POST"
api_headers: dict[str, str] = field(default_factory=dict)
api_auth_type: str | None = None # bearer, api_key, basic
api_auth_env_var: str | None = None # Env var containing the secret
# Response mapping
response_mapping: dict[str, str] = field(default_factory=dict)
def to_dict(self) -> dict[str, Any]:
base = super().to_dict()
base.update({
"remoteUrl": self.remote_url,
"trustedIdentity": self.trusted_identity,
"apiEndpoint": self.api_endpoint,
"apiMethod": self.api_method,
"apiHeaders": self.api_headers,
"apiAuthType": self.api_auth_type,
"apiAuthEnvVar": self.api_auth_env_var,
"responseMapping": self.response_mapping,
})
return base
@classmethod
def from_dict(cls, data: dict[str, Any]) -> GatewayNode:
position = data.get("position", {})
return cls(
id=UUID(data["id"]) if isinstance(data.get("id"), str) else data.get("id", uuid4()),
name=data["name"],
description=data.get("description", ""),
position_x=position.get("x", 0.0),
position_y=position.get("y", 0.0),
color=data.get("color"),
icon=data.get("icon"),
remote_url=data.get("remoteUrl") or data.get("remote_url"),
trusted_identity=data.get("trustedIdentity") or data.get("trusted_identity"),
api_endpoint=data.get("apiEndpoint") or data.get("api_endpoint"),
api_method=data.get("apiMethod") or data.get("api_method", "POST"),
api_headers=data.get("apiHeaders") or data.get("api_headers", {}),
api_auth_type=data.get("apiAuthType") or data.get("api_auth_type"),
api_auth_env_var=data.get("apiAuthEnvVar") or data.get("api_auth_env_var"),
response_mapping=data.get("responseMapping") or data.get("response_mapping", {}),
)
def to_listener_config(self) -> dict[str, Any]:
"""Convert to organism.yaml listener/gateway configuration."""
if self.remote_url:
# Federation gateway
return {
"name": self.name,
"remote_url": self.remote_url,
"trusted_identity": self.trusted_identity,
"description": self.description,
}
elif self.api_endpoint:
# REST API gateway (custom handler)
return {
"name": self.name,
"description": self.description,
"handler": "bloxserver.gateways.rest.handle_rest_gateway",
"payload_class": "bloxserver.gateways.rest.RestGatewayPayload",
"gateway_config": {
"endpoint": self.api_endpoint,
"method": self.api_method,
"headers": self.api_headers,
"auth_type": self.api_auth_type,
"auth_env_var": self.api_auth_env_var,
"response_mapping": self.response_mapping,
},
}
else:
raise ValueError(
f"Gateway '{self.name}' requires either remote_url (federation) "
"or api_endpoint (REST)"
)