chore: import upstream snapshot with attribution
Validate YAML Workflows / Validate YAML Configuration Files (push) Has been cancelled

This commit is contained in:
wehub-resource-sync
2026-07-13 12:37:51 +08:00
commit d0e4308def
614 changed files with 74458 additions and 0 deletions
View File
+33
View File
@@ -0,0 +1,33 @@
"""Helpers for loading validated configuration objects."""
from pathlib import Path
from typing import Any, Mapping
import yaml
from entity.configs import DesignConfig, ConfigError
from utils.env_loader import load_dotenv_file, build_env_var_map
from utils.vars_resolver import resolve_design_placeholders
def prepare_design_mapping(data: Mapping[str, Any], *, source: str | None = None) -> Mapping[str, Any]:
load_dotenv_file()
env_lookup = build_env_var_map()
prepared = dict(data)
resolve_design_placeholders(prepared, env_lookup=env_lookup, path=source or "root")
return prepared
def load_design_from_mapping(data: Mapping[str, Any], *, source: str | None = None) -> DesignConfig:
"""Parse a raw dictionary into a typed :class:`DesignConfig`."""
prepared = prepare_design_mapping(data, source=source)
return DesignConfig.from_dict(prepared, path="root")
def load_design_from_file(path: Path) -> DesignConfig:
"""Read a YAML file and parse it into a :class:`DesignConfig`."""
with path.open("r", encoding="utf-8") as handle:
data = yaml.load(handle, Loader=yaml.FullLoader)
if not isinstance(data, Mapping):
raise ConfigError("YAML root must be a mapping", path=str(path))
return load_design_from_mapping(data, source=str(path))
+58
View File
@@ -0,0 +1,58 @@
"""Configuration package exports."""
from .base import BaseConfig, ConfigError
from .edge.edge import EdgeConfig
from .edge.edge_condition import EdgeConditionConfig, FunctionEdgeConditionConfig, KeywordEdgeConditionConfig
from .edge.edge_processor import EdgeProcessorConfig, RegexEdgeProcessorConfig, FunctionEdgeProcessorConfig
from .graph import DesignConfig, GraphDefinition
from .node.memory import (
BlackboardMemoryConfig,
EmbeddingConfig,
FileMemoryConfig,
FileSourceConfig,
Mem0MemoryConfig,
MemoryAttachmentConfig,
MemoryStoreConfig,
SimpleMemoryConfig,
)
from .node.agent import AgentConfig, AgentRetryConfig
from .node.human import HumanConfig
from .node.subgraph import SubgraphConfig
from .node.node import EdgeLink, Node
from .node.passthrough import PassthroughConfig
from .node.python_runner import PythonRunnerConfig
from .node.skills import AgentSkillsConfig
from .node.thinking import ReflectionThinkingConfig, ThinkingConfig
from .node.tooling import FunctionToolConfig, McpLocalConfig, McpRemoteConfig, ToolingConfig
__all__ = [
"AgentConfig",
"AgentRetryConfig",
"AgentSkillsConfig",
"BaseConfig",
"ConfigError",
"DesignConfig",
"EdgeConfig",
"EdgeConditionConfig",
"EdgeLink",
"EdgeProcessorConfig",
"RegexEdgeProcessorConfig",
"FunctionEdgeProcessorConfig",
"BlackboardMemoryConfig",
"EmbeddingConfig",
"FileSourceConfig",
"FunctionToolConfig",
"GraphDefinition",
"HumanConfig",
"Mem0MemoryConfig",
"MemoryAttachmentConfig",
"MemoryStoreConfig",
"McpLocalConfig",
"McpRemoteConfig",
"Node",
"PassthroughConfig",
"PythonRunnerConfig",
"SubgraphConfig",
"ThinkingConfig",
"ToolingConfig",
]
+276
View File
@@ -0,0 +1,276 @@
"""Shared helpers and base classes for configuration dataclasses."""
from dataclasses import dataclass, field, replace
from typing import Any, Dict, Iterable, List, Mapping, MutableMapping, Sequence, TypeVar, ClassVar, Optional
TConfig = TypeVar("TConfig", bound="BaseConfig")
class ConfigError(ValueError):
"""Raised when configuration parsing or validation fails."""
def __init__(self, message: str, path: str | None = None):
self.path = path
full_message = f"{path}: {message}" if path else message
super().__init__(full_message)
@dataclass(frozen=True)
class RuntimeConstraint:
"""Represents a conditional requirement for configuration fields."""
when: Mapping[str, Any]
require: Sequence[str]
message: str
def to_json(self) -> Dict[str, Any]:
return {
"when": dict(self.when),
"require": list(self.require),
"message": self.message,
}
@dataclass(frozen=True)
class ChildKey:
"""Identifies a conditional navigation target for nested schemas."""
field: str
value: Any | None = None
# variant: str | None = None
def matches(self, field: str, value: Any | None) -> bool:
if self.field != field:
return False
# if self.variant is not None and self.variant != str(value):
# return False
if self.value is None:
return True
return self.value == value
def to_json(self) -> Dict[str, Any]:
payload: Dict[str, Any] = {"field": self.field}
if self.value is not None:
payload["value"] = self.value
# if self.variant is not None:
# payload["variant"] = self.variant
return payload
@dataclass(frozen=True)
class EnumOption:
"""Rich metadata for enum values shown in UI."""
value: Any
label: str | None = None
description: str | None = None
def to_json(self) -> Dict[str, Any]:
payload: Dict[str, Any] = {"value": self.value}
if self.label:
payload["label"] = self.label
if self.description:
payload["description"] = self.description
return payload
@dataclass(frozen=True)
class ConfigFieldSpec:
"""Describes a single configuration field for schema export."""
name: str
type_hint: str
required: bool = False
display_name: str | None = None
default: Any | None = None
enum: Sequence[Any] | None = None
enum_options: Sequence[EnumOption] | None = None
description: str | None = None
child: type["BaseConfig"] | None = None
advance: bool = False
# ui: Mapping[str, Any] | None = None
def with_name(self, name: str) -> "ConfigFieldSpec":
if self.name == name:
return self
return replace(self, name=name)
def to_json(self) -> Dict[str, Any]:
display = self.display_name or self.name
data: Dict[str, Any] = {
"name": self.name,
"displayName": display,
"type": self.type_hint,
"required": self.required,
"advance": self.advance,
}
if self.default is not None:
data["default"] = self.default
if self.enum is not None:
data["enum"] = list(self.enum)
if self.enum_options:
data["enumOptions"] = [option.to_json() for option in self.enum_options]
if self.description:
data["description"] = self.description
if self.child is not None:
data["childNode"] = self.child.__name__
# if self.ui:
# data["ui"] = dict(self.ui)
return data
@dataclass(frozen=True)
class SchemaNode:
"""Serializable representation of a configuration node."""
node: str
fields: Sequence[ConfigFieldSpec]
constraints: Sequence[RuntimeConstraint] = field(default_factory=list)
def to_json(self) -> Dict[str, Any]:
return {
"node": self.node,
"fields": [spec.to_json() for spec in self.fields],
"constraints": [constraint.to_json() for constraint in self.constraints],
}
@dataclass
class BaseConfig:
"""Base dataclass providing validation and schema hooks."""
path: str
# Class-level hooks populated by concrete configs.
FIELD_SPECS: ClassVar[Dict[str, ConfigFieldSpec]] = {}
CONSTRAINTS: ClassVar[Sequence[RuntimeConstraint]] = ()
CHILD_ROUTES: ClassVar[Dict[ChildKey, type["BaseConfig"]]] = {}
def __post_init__(self) -> None: # pragma: no cover - thin wrapper
self.validate()
def validate(self) -> None:
"""Hook for subclasses to implement structural validation."""
# Default implementation intentionally empty.
return None
@classmethod
def field_specs(cls) -> Dict[str, ConfigFieldSpec]:
return {name: spec.with_name(name) for name, spec in getattr(cls, "FIELD_SPECS", {}).items()}
@classmethod
def constraints(cls) -> Sequence[RuntimeConstraint]:
return tuple(getattr(cls, "CONSTRAINTS", ()) or ())
@classmethod
def child_routes(cls) -> Dict[ChildKey, type["BaseConfig"]]:
return dict(getattr(cls, "CHILD_ROUTES", {}) or {})
@classmethod
def resolve_child(cls, field: str, value: Any | None = None) -> type["BaseConfig"] | None:
for key, target in cls.child_routes().items():
if key.matches(field, value):
return target
return None
def as_config(self, expected_type: type[TConfig], *, attr: str = "config") -> TConfig | None:
"""Return the nested config stored under *attr* if it matches the expected type."""
value = getattr(self, attr, None)
if isinstance(value, expected_type):
return value
return None
@classmethod
def collect_schema(cls) -> SchemaNode:
return SchemaNode(node=cls.__name__, fields=list(cls.field_specs().values()), constraints=list(cls.constraints()))
@classmethod
def example(cls) -> Dict[str, Any]:
"""Placeholder for future example export support."""
return {}
T = TypeVar("T")
def ensure_list(value: Any) -> List[Any]:
if value is None:
return []
if isinstance(value, list):
return list(value)
if isinstance(value, (tuple, set)):
return list(value)
return [value]
def ensure_dict(value: Mapping[str, Any] | None) -> Dict[str, Any]:
if value is None:
return {}
if isinstance(value, MutableMapping):
return dict(value)
if isinstance(value, Mapping):
return dict(value)
raise ConfigError("expected mapping", path=str(value))
def require_mapping(data: Any, path: str) -> Mapping[str, Any]:
if not isinstance(data, Mapping):
raise ConfigError("expected mapping", path)
return data
def require_str(data: Mapping[str, Any], key: str, path: str, *, allow_empty: bool = False) -> str:
value = data.get(key)
key_path = f"{path}.{key}" if path else key
if not isinstance(value, str):
raise ConfigError("expected string", key_path)
if not allow_empty and not value.strip():
raise ConfigError("expected non-empty string", key_path)
return value
def optional_str(data: Mapping[str, Any], key: str, path: str) -> str | None:
value = data.get(key)
if value is None or value == "":
return None
key_path = f"{path}.{key}" if path else key
if not isinstance(value, str):
raise ConfigError("expected string", key_path)
return value
def require_bool(data: Mapping[str, Any], key: str, path: str) -> bool:
value = data.get(key)
key_path = f"{path}.{key}" if path else key
if not isinstance(value, bool):
raise ConfigError("expected boolean", key_path)
return value
def optional_bool(data: Mapping[str, Any], key: str, path: str, *, default: bool | None = None) -> bool | None:
if key not in data:
return default
value = data[key]
key_path = f"{path}.{key}" if path else key
if not isinstance(value, bool):
raise ConfigError("expected boolean", key_path)
return value
def optional_dict(data: Mapping[str, Any], key: str, path: str) -> Dict[str, Any] | None:
if key not in data or data[key] is None:
return None
value = data[key]
key_path = f"{path}.{key}" if path else key
if not isinstance(value, Mapping):
raise ConfigError("expected mapping", key_path)
return dict(value)
def extend_path(path: str, suffix: str) -> str:
if not path:
return suffix
if suffix.startswith("["):
return f"{path}{suffix}"
return f"{path}.{suffix}"
+443
View File
@@ -0,0 +1,443 @@
"""Shared dynamic configuration classes for both node and edge level execution.
This module contains the base classes used by both node-level and edge-level
dynamic execution configurations to avoid circular imports.
"""
from dataclasses import dataclass, fields, replace
from typing import Any, ClassVar, Dict, Mapping, Optional, Type, TypeVar
from entity.configs.base import (
BaseConfig,
ChildKey,
ConfigError,
ConfigFieldSpec,
extend_path,
optional_bool,
optional_str,
require_mapping,
require_str,
)
from entity.enum_options import enum_options_from_values
def _serialize_config(config: BaseConfig) -> Dict[str, Any]:
"""Serialize a config to dict, excluding the path field."""
payload: Dict[str, Any] = {}
for field_obj in fields(config):
if field_obj.name == "path":
continue
payload[field_obj.name] = getattr(config, field_obj.name)
return payload
class SplitTypeConfig(BaseConfig):
"""Base helper class for split type configs."""
def display_label(self) -> str:
return self.__class__.__name__
def to_external_value(self) -> Any:
return _serialize_config(self)
@dataclass
class MessageSplitConfig(SplitTypeConfig):
"""Configuration for message-based splitting.
Each input message becomes one execution unit. No additional configuration needed.
"""
FIELD_SPECS: ClassVar[Dict[str, ConfigFieldSpec]] = {}
@classmethod
def from_dict(cls, data: Mapping[str, Any] | None, *, path: str) -> "MessageSplitConfig":
# No config needed for message split
return cls(path=path)
def display_label(self) -> str:
return "message"
_NO_MATCH_DESCRIPTIONS = {
"pass": "Leave the content unchanged when no match is found.",
"empty": "Return empty content when no match is found.",
}
@dataclass
class RegexSplitConfig(SplitTypeConfig):
"""Configuration for regex-based splitting.
Split content by regex pattern matches. Each match becomes one execution unit.
Attributes:
pattern: Python regular expression used to split content.
group: Capture group name or index. Defaults to the entire match (group 0).
case_sensitive: Whether the regex should be case sensitive.
multiline: Enable multiline mode (re.MULTILINE).
dotall: Enable dotall mode (re.DOTALL).
on_no_match: Behavior when no match is found.
"""
pattern: str = ""
group: str | int | None = None
case_sensitive: bool = True
multiline: bool = False
dotall: bool = False
on_no_match: str = "pass"
FIELD_SPECS = {
"pattern": ConfigFieldSpec(
name="pattern",
display_name="Regex Pattern",
type_hint="str",
required=True,
description="Python regular expression used to split content.",
),
"group": ConfigFieldSpec(
name="group",
display_name="Capture Group",
type_hint="str",
required=False,
description="Capture group name or index. Defaults to the entire match (group 0).",
),
"case_sensitive": ConfigFieldSpec(
name="case_sensitive",
display_name="Case Sensitive",
type_hint="bool",
required=False,
default=True,
description="Whether the regex should be case sensitive.",
),
"multiline": ConfigFieldSpec(
name="multiline",
display_name="Multiline Flag",
type_hint="bool",
required=False,
default=False,
description="Enable multiline mode (re.MULTILINE).",
advance=True,
),
"dotall": ConfigFieldSpec(
name="dotall",
display_name="Dotall Flag",
type_hint="bool",
required=False,
default=False,
description="Enable dotall mode (re.DOTALL).",
advance=True,
),
"on_no_match": ConfigFieldSpec(
name="on_no_match",
display_name="No Match Behavior",
type_hint="enum",
required=False,
default="pass",
enum=["pass", "empty"],
description="Behavior when no match is found.",
enum_options=enum_options_from_values(
list(_NO_MATCH_DESCRIPTIONS.keys()),
_NO_MATCH_DESCRIPTIONS,
preserve_label_case=True,
),
advance=True,
),
}
@classmethod
def from_dict(cls, data: Mapping[str, Any], *, path: str) -> "RegexSplitConfig":
mapping = require_mapping(data, path)
pattern = require_str(mapping, "pattern", path, allow_empty=False)
group_value = mapping.get("group")
group_normalized: str | int | None = None
if group_value is not None:
if isinstance(group_value, int):
group_normalized = group_value
elif isinstance(group_value, str):
if group_value.isdigit():
group_normalized = int(group_value)
else:
group_normalized = group_value
else:
raise ConfigError("group must be str or int", extend_path(path, "group"))
case_sensitive = optional_bool(mapping, "case_sensitive", path, default=True)
multiline = optional_bool(mapping, "multiline", path, default=False)
dotall = optional_bool(mapping, "dotall", path, default=False)
on_no_match = optional_str(mapping, "on_no_match", path) or "pass"
if on_no_match not in {"pass", "empty"}:
raise ConfigError("on_no_match must be 'pass' or 'empty'", extend_path(path, "on_no_match"))
return cls(
pattern=pattern,
group=group_normalized,
case_sensitive=True if case_sensitive is None else bool(case_sensitive),
multiline=bool(multiline) if multiline is not None else False,
dotall=bool(dotall) if dotall is not None else False,
on_no_match=on_no_match,
path=path,
)
def display_label(self) -> str:
return f"regex({self.pattern})"
@dataclass
class JsonPathSplitConfig(SplitTypeConfig):
"""Configuration for JSON path-based splitting.
Split content by extracting array items from JSON using a path expression.
Each array item becomes one execution unit.
Attributes:
json_path: Simple dot-notation path to array (e.g., 'items', 'data.results').
"""
json_path: str = ""
FIELD_SPECS = {
"json_path": ConfigFieldSpec(
name="json_path",
display_name="JSON Path",
type_hint="str",
required=True,
description="Simple dot-notation path to array (e.g., 'items', 'data.results').",
),
}
@classmethod
def from_dict(cls, data: Mapping[str, Any], *, path: str) -> "JsonPathSplitConfig":
mapping = require_mapping(data, path)
json_path_value = require_str(mapping, "json_path", path, allow_empty=True)
return cls(json_path=json_path_value, path=path)
def display_label(self) -> str:
return f"json_path({self.json_path})"
# Registry for split types
_SPLIT_TYPE_REGISTRY: Dict[str, Dict[str, Any]] = {
"message": {
"config_cls": MessageSplitConfig,
"summary": "Each input message becomes one unit",
},
"regex": {
"config_cls": RegexSplitConfig,
"summary": "Split by regex pattern matches",
},
"json_path": {
"config_cls": JsonPathSplitConfig,
"summary": "Split by JSON array path",
},
}
def get_split_type_config(name: str) -> Type[SplitTypeConfig]:
"""Get the config class for a split type."""
entry = _SPLIT_TYPE_REGISTRY.get(name)
if not entry:
raise ConfigError(f"Unknown split type: {name}", None)
return entry["config_cls"]
def iter_split_type_registrations() -> Dict[str, Type[SplitTypeConfig]]:
"""Iterate over all registered split types."""
return {name: entry["config_cls"] for name, entry in _SPLIT_TYPE_REGISTRY.items()}
def iter_split_type_metadata() -> Dict[str, Dict[str, Any]]:
"""Iterate over split type metadata."""
return {name: {"summary": entry.get("summary")} for name, entry in _SPLIT_TYPE_REGISTRY.items()}
TSplitConfig = TypeVar("TSplitConfig", bound=SplitTypeConfig)
@dataclass
class SplitConfig(BaseConfig):
"""Configuration for how to split inputs into execution units.
Attributes:
type: Split strategy type (message, regex, json_path)
config: Type-specific configuration
"""
type: str = "message"
config: SplitTypeConfig | None = None
FIELD_SPECS = {
"type": ConfigFieldSpec(
name="type",
display_name="Split Type",
type_hint="str",
required=True,
default="message",
description="Strategy for splitting inputs into parallel execution units",
),
"config": ConfigFieldSpec(
name="config",
display_name="Split Config",
type_hint="object",
required=False,
description="Type-specific split configuration",
),
}
@classmethod
def child_routes(cls) -> Dict[ChildKey, Type[BaseConfig]]:
return {
ChildKey(field="config", value=name): config_cls
for name, config_cls in iter_split_type_registrations().items()
}
@classmethod
def field_specs(cls) -> Dict[str, ConfigFieldSpec]:
specs = super().field_specs()
type_spec = specs.get("type")
if type_spec:
registrations = iter_split_type_registrations()
metadata = iter_split_type_metadata()
type_names = list(registrations.keys())
descriptions = {name: (metadata.get(name) or {}).get("summary") for name in type_names}
specs["type"] = replace(
type_spec,
enum=type_names,
enum_options=enum_options_from_values(type_names, descriptions),
)
return specs
@classmethod
def from_dict(cls, data: Mapping[str, Any] | None, *, path: str) -> "SplitConfig":
if data is None:
# Default to message split
return cls(type="message", config=MessageSplitConfig(path=extend_path(path, "config")), path=path)
mapping = require_mapping(data, path)
split_type = optional_str(mapping, "type", path) or "message"
if split_type not in _SPLIT_TYPE_REGISTRY:
raise ConfigError(
f"split type must be one of {list(_SPLIT_TYPE_REGISTRY.keys())}, got '{split_type}'",
extend_path(path, "type"),
)
config_cls = get_split_type_config(split_type)
config_data = mapping.get("config")
config_path = extend_path(path, "config")
# For message type, config is optional
if split_type == "message":
config = config_cls.from_dict(config_data, path=config_path)
else:
if config_data is None:
raise ConfigError(f"{split_type} split requires 'config' field", path)
config = config_cls.from_dict(config_data, path=config_path)
return cls(type=split_type, config=config, path=path)
def display_label(self) -> str:
if self.config:
return self.config.display_label()
return self.type
def to_external_value(self) -> Any:
return {
"type": self.type,
"config": self.config.to_external_value() if self.config else {},
}
def as_split_config(self, expected_type: Type[TSplitConfig]) -> TSplitConfig | None:
"""Return the nested config if it matches the expected type."""
if isinstance(self.config, expected_type):
return self.config
return None
# Convenience properties for backward compatibility and easy access
@property
def pattern(self) -> Optional[str]:
"""Get regex pattern if this is a regex split."""
if isinstance(self.config, RegexSplitConfig):
return self.config.pattern
return None
@property
def json_path(self) -> Optional[str]:
"""Get json_path if this is a json_path split."""
if isinstance(self.config, JsonPathSplitConfig):
return self.config.json_path
return None
@dataclass
class MapDynamicConfig(BaseConfig):
"""Configuration for Map dynamic mode (fan-out only).
Map mode is similar to passthrough - minimal config required.
Attributes:
max_parallel: Maximum concurrent executions
"""
max_parallel: int = 10
FIELD_SPECS = {
"max_parallel": ConfigFieldSpec(
name="max_parallel",
display_name="Max Parallel",
type_hint="int",
required=False,
default=10,
description="Maximum number of parallel executions",
),
}
@classmethod
def from_dict(cls, data: Mapping[str, Any] | None, *, path: str) -> "MapDynamicConfig":
if data is None:
return cls(path=path)
mapping = require_mapping(data, path)
max_parallel = int(mapping.get("max_parallel", 10))
return cls(max_parallel=max_parallel, path=path)
@dataclass
class TreeDynamicConfig(BaseConfig):
"""Configuration for Tree dynamic mode (fan-out and reduce).
Attributes:
group_size: Number of items per group in reduction
max_parallel: Maximum concurrent executions per layer
"""
group_size: int = 3
max_parallel: int = 10
FIELD_SPECS = {
"group_size": ConfigFieldSpec(
name="group_size",
display_name="Group Size",
type_hint="int",
required=False,
default=3,
description="Number of items per group during reduction",
),
"max_parallel": ConfigFieldSpec(
name="max_parallel",
display_name="Max Parallel",
type_hint="int",
required=False,
default=10,
description="Maximum concurrent executions per layer",
),
}
@classmethod
def from_dict(cls, data: Mapping[str, Any] | None, *, path: str) -> "TreeDynamicConfig":
if data is None:
return cls(path=path)
mapping = require_mapping(data, path)
group_size = int(mapping.get("group_size", 3))
if group_size < 2:
raise ConfigError("group_size must be at least 2", extend_path(path, "group_size"))
max_parallel = int(mapping.get("max_parallel", 10))
return cls(group_size=group_size, max_parallel=max_parallel, path=path)
+17
View File
@@ -0,0 +1,17 @@
from .edge import EdgeConfig
from .edge_condition import EdgeConditionConfig
from .edge_processor import (
EdgeProcessorConfig,
RegexEdgeProcessorConfig,
FunctionEdgeProcessorConfig,
)
from .dynamic_edge_config import DynamicEdgeConfig
__all__ = [
"EdgeConfig",
"EdgeConditionConfig",
"EdgeProcessorConfig",
"RegexEdgeProcessorConfig",
"FunctionEdgeProcessorConfig",
"DynamicEdgeConfig",
]
+183
View File
@@ -0,0 +1,183 @@
"""Dynamic edge configuration for edge-level Map and Tree execution modes."""
from dataclasses import dataclass, field, replace
from typing import Any, Dict, Mapping
from entity.configs.base import (
BaseConfig,
ConfigError,
ConfigFieldSpec,
ChildKey,
extend_path,
require_mapping,
require_str,
)
from entity.configs.dynamic_base import (
SplitConfig,
MapDynamicConfig,
TreeDynamicConfig,
)
from entity.enum_options import enum_options_from_values
from utils.registry import Registry, RegistryError
# Local registry for edge-level dynamic types (reuses same type names)
dynamic_edge_type_registry = Registry("dynamic_edge_type")
def register_dynamic_edge_type(
name: str,
*,
config_cls: type[BaseConfig],
description: str | None = None,
) -> None:
metadata = {"summary": description} if description else None
dynamic_edge_type_registry.register(name, target=config_cls, metadata=metadata)
def get_dynamic_edge_type_config(name: str) -> type[BaseConfig]:
entry = dynamic_edge_type_registry.get(name)
config_cls = entry.load()
if not isinstance(config_cls, type) or not issubclass(config_cls, BaseConfig):
raise RegistryError(f"Entry '{name}' is not a BaseConfig subclass")
return config_cls
def iter_dynamic_edge_type_registrations() -> Dict[str, type[BaseConfig]]:
return {name: entry.load() for name, entry in dynamic_edge_type_registry.items()}
def iter_dynamic_edge_type_metadata() -> Dict[str, Dict[str, Any]]:
return {name: dict(entry.metadata or {}) for name, entry in dynamic_edge_type_registry.items()}
@dataclass
class DynamicEdgeConfig(BaseConfig):
"""Dynamic configuration for edge-level Map and Tree execution modes.
When configured on an edge, the target node will be dynamically expanded
based on the split results. The split logic is applied to messages
passing through this edge.
Attributes:
type: Dynamic mode type (map or tree)
split: How to split the payload passing through this edge
config: Mode-specific configuration (MapDynamicConfig or TreeDynamicConfig)
"""
type: str
split: SplitConfig = field(default_factory=lambda: SplitConfig())
config: BaseConfig | None = None
FIELD_SPECS = {
"type": ConfigFieldSpec(
name="type",
display_name="Dynamic Type",
type_hint="str",
required=True,
description="Dynamic execution mode (map or tree)",
),
"split": ConfigFieldSpec(
name="split",
display_name="Split Strategy",
type_hint="SplitConfig",
required=False,
description="How to split the edge payload into parallel execution units",
child=SplitConfig,
),
"config": ConfigFieldSpec(
name="config",
display_name="Dynamic Config",
type_hint="object",
required=False,
description="Mode-specific configuration",
),
}
@classmethod
def child_routes(cls) -> Dict[ChildKey, type[BaseConfig]]:
return {
ChildKey(field="config", value=name): config_cls
for name, config_cls in iter_dynamic_edge_type_registrations().items()
}
@classmethod
def field_specs(cls) -> Dict[str, ConfigFieldSpec]:
specs = super().field_specs()
type_spec = specs.get("type")
if type_spec:
registrations = iter_dynamic_edge_type_registrations()
metadata = iter_dynamic_edge_type_metadata()
type_names = list(registrations.keys())
descriptions = {name: (metadata.get(name) or {}).get("summary") for name in type_names}
specs["type"] = replace(
type_spec,
enum=type_names,
enum_options=enum_options_from_values(type_names, descriptions),
)
return specs
@classmethod
def from_dict(cls, data: Mapping[str, Any] | None, *, path: str) -> "DynamicEdgeConfig | None":
if data is None:
return None
mapping = require_mapping(data, path)
dynamic_type = require_str(mapping, "type", path)
try:
config_cls = get_dynamic_edge_type_config(dynamic_type)
except RegistryError as exc:
raise ConfigError(
f"dynamic type must be one of {list(iter_dynamic_edge_type_registrations().keys())}",
extend_path(path, "type"),
) from exc
# Parse split at top level
split_data = mapping.get("split")
split = SplitConfig.from_dict(split_data, path=extend_path(path, "split"))
# Parse mode-specific config
config_data = mapping.get("config")
config_path = extend_path(path, "config")
config = config_cls.from_dict(config_data, path=config_path)
return cls(type=dynamic_type, split=split, config=config, path=path)
def is_map(self) -> bool:
return self.type == "map"
def is_tree(self) -> bool:
return self.type == "tree"
def as_map_config(self) -> MapDynamicConfig | None:
return self.config if self.is_map() and isinstance(self.config, MapDynamicConfig) else None
def as_tree_config(self) -> TreeDynamicConfig | None:
return self.config if self.is_tree() and isinstance(self.config, TreeDynamicConfig) else None
@property
def max_parallel(self) -> int:
"""Get max_parallel from config."""
if hasattr(self.config, "max_parallel"):
return getattr(self.config, "max_parallel")
return 10
@property
def group_size(self) -> int:
"""Get group_size (tree mode only, defaults to 3)."""
if isinstance(self.config, TreeDynamicConfig):
return self.config.group_size
return 3
# Register dynamic edge types
register_dynamic_edge_type(
"map",
config_cls=MapDynamicConfig,
description="Fan-out only: split into parallel units and collect results",
)
register_dynamic_edge_type(
"tree",
config_cls=TreeDynamicConfig,
description="Fan-out and reduce: split into units, then iteratively reduce results",
)
+151
View File
@@ -0,0 +1,151 @@
"""Edge configuration dataclasses."""
from dataclasses import dataclass, field
from typing import Any, Dict, Mapping
from entity.configs.base import (
BaseConfig,
ConfigFieldSpec,
require_mapping,
require_str,
optional_bool,
extend_path,
)
from .edge_condition import EdgeConditionConfig
from .edge_processor import EdgeProcessorConfig
from .dynamic_edge_config import DynamicEdgeConfig
@dataclass
class EdgeConfig(BaseConfig):
source: str
target: str
trigger: bool = True
condition: EdgeConditionConfig | None = None
carry_data: bool = True
keep_message: bool = False
clear_context: bool = False
clear_kept_context: bool = False
process: EdgeProcessorConfig | None = None
dynamic: DynamicEdgeConfig | None = None
@classmethod
def from_dict(cls, data: Mapping[str, Any], *, path: str) -> "EdgeConfig":
mapping = require_mapping(data, path)
source = require_str(mapping, "from", path)
target = require_str(mapping, "to", path)
trigger_value = optional_bool(mapping, "trigger", path, default=True)
carry_data_value = optional_bool(mapping, "carry_data", path, default=True)
keep_message_value = optional_bool(mapping, "keep_message", path, default=False)
clear_context_value = optional_bool(mapping, "clear_context", path, default=False)
clear_kept_context_value = optional_bool(mapping, "clear_kept_context", path, default=False)
condition_value = mapping.get("condition", "true")
condition_cfg = EdgeConditionConfig.from_dict(condition_value, path=extend_path(path, "condition"))
process_cfg = None
if "process" in mapping and mapping["process"] is not None:
process_cfg = EdgeProcessorConfig.from_dict(mapping["process"], path=extend_path(path, "process"))
dynamic_cfg = None
if "dynamic" in mapping and mapping["dynamic"] is not None:
dynamic_cfg = DynamicEdgeConfig.from_dict(mapping["dynamic"], path=extend_path(path, "dynamic"))
return cls(
source=source,
target=target,
trigger=bool(trigger_value) if trigger_value is not None else True,
condition=condition_cfg,
carry_data=bool(carry_data_value) if carry_data_value is not None else True,
keep_message=bool(keep_message_value) if keep_message_value is not None else False,
clear_context=bool(clear_context_value) if clear_context_value is not None else False,
clear_kept_context=bool(clear_kept_context_value) if clear_kept_context_value is not None else False,
process=process_cfg,
dynamic=dynamic_cfg,
path=path,
)
FIELD_SPECS = {
"from": ConfigFieldSpec(
name="from",
display_name="Source Node ID",
type_hint="str",
required=True,
description="Source node ID of the edge",
),
"to": ConfigFieldSpec(
name="to",
display_name="Target Node ID",
type_hint="str",
required=True,
description="Target node ID of the edge",
),
"trigger": ConfigFieldSpec(
name="trigger",
type_hint="bool",
required=False,
default=True,
display_name="Can Trigger Successor",
description="Whether this edge can trigger successor nodes",
advance=True,
),
"condition": ConfigFieldSpec(
name="condition",
type_hint="EdgeConditionConfig",
required=False,
display_name="Edge Condition",
description="Edge condition configurationtype + config",
advance=True,
child=EdgeConditionConfig,
),
"carry_data": ConfigFieldSpec(
name="carry_data",
type_hint="bool",
required=False,
default=True,
display_name="Pass Data to Target",
description="Whether to pass data to the target node",
advance=True,
),
"keep_message": ConfigFieldSpec(
name="keep_message",
type_hint="bool",
required=False,
default=False,
display_name="Keep Message Input",
description="Whether to always keep this message input in the target node without being cleared",
advance=True,
),
"clear_context": ConfigFieldSpec(
name="clear_context",
type_hint="bool",
required=False,
default=False,
display_name="Clear Context",
description="Clear all incoming context messages without keep=True before passing new payload",
advance=True,
),
"clear_kept_context": ConfigFieldSpec(
name="clear_kept_context",
type_hint="bool",
required=False,
default=False,
display_name="Clear Kept Context",
description="Clear messages marked with keep=True before passing new payload",
advance=True,
),
"process": ConfigFieldSpec(
name="process",
type_hint="EdgeProcessorConfig",
required=False,
display_name="Payload Processor",
description="Optional payload processor applied after the condition is met (regex extraction, custom functions, etc.)",
advance=True,
child=EdgeProcessorConfig,
),
"dynamic": ConfigFieldSpec(
name="dynamic",
type_hint="DynamicEdgeConfig",
required=False,
display_name="Dynamic Expansion",
description="Dynamic expansion configuration for edge-level Map (fan-out) or Tree (fan-out + reduce) modes. When set, the target node is dynamically expanded based on split results.",
advance=True,
child=DynamicEdgeConfig,
),
}
+302
View File
@@ -0,0 +1,302 @@
"""Edge condition configuration models."""
from dataclasses import dataclass, field, fields, replace
from typing import Any, Dict, Mapping, Type, TypeVar, cast
from entity.enum_options import enum_options_from_values
from schema_registry import (
SchemaLookupError,
get_edge_condition_schema,
iter_edge_condition_schemas,
)
from entity.configs.base import (
BaseConfig,
ChildKey,
ConfigError,
ConfigFieldSpec,
ensure_list,
optional_bool,
require_mapping,
require_str,
extend_path,
)
from utils.function_catalog import get_function_catalog
from utils.function_manager import EDGE_FUNCTION_DIR
def _serialize_config(config: BaseConfig) -> Dict[str, Any]:
payload: Dict[str, Any] = {}
for field_obj in fields(config):
if field_obj.name == "path":
continue
payload[field_obj.name] = getattr(config, field_obj.name)
return payload
class EdgeConditionTypeConfig(BaseConfig):
"""Base helper for condition-specific configuration classes."""
def display_label(self) -> str:
return self.__class__.__name__
def to_external_value(self) -> Any:
return _serialize_config(self)
@dataclass
class FunctionEdgeConditionConfig(EdgeConditionTypeConfig):
"""Configuration for function-based conditions."""
name: str = "true"
FIELD_SPECS = {
"name": ConfigFieldSpec(
name="name",
display_name="Function Name",
type_hint="str",
required=True,
default="true",
description="Function Name or 'true' (indicating perpetual satisfaction)",
)
}
@classmethod
def from_dict(cls, data: Mapping[str, Any] | None, *, path: str) -> "FunctionEdgeConditionConfig":
if data is None:
return cls(name="true", path=path)
mapping = require_mapping(data, path)
function_name = require_str(mapping, "name", path, allow_empty=False)
return cls(name=function_name, path=path)
@classmethod
def field_specs(cls) -> Dict[str, ConfigFieldSpec]:
specs = super().field_specs()
name_spec = specs.get("name")
if name_spec is None:
return specs
catalog = get_function_catalog(EDGE_FUNCTION_DIR)
names = catalog.list_function_names()
metadata = catalog.list_metadata()
description = name_spec.description or "Conditional function name"
if catalog.load_error:
description = f"{description} (Loading failed: {catalog.load_error})"
elif not names:
description = f"{description} (No available conditional functions found)"
if "true" not in names:
names.insert(0, "true")
descriptions = {"true": "Default condition (always met)"}
for name in names:
if name == "true":
continue
meta = metadata.get(name)
descriptions[name] = (meta.description if meta else None) or "The conditional function is not described."
specs["name"] = replace(
name_spec,
enum=names or None,
enum_options=enum_options_from_values(names, descriptions, preserve_label_case=True),
description=description,
)
return specs
def display_label(self) -> str:
return self.name or "true"
def to_external_value(self) -> Any:
return self.name or "true"
def _normalize_keyword_list(value: Any, path: str) -> list[str]:
items = ensure_list(value)
normalized: list[str] = []
for idx, item in enumerate(items):
if not isinstance(item, str):
raise ConfigError("entries must be strings", extend_path(path, f"[{idx}]"))
normalized.append(item)
return normalized
@dataclass
class KeywordEdgeConditionConfig(EdgeConditionTypeConfig):
"""Configuration for declarative keyword checks."""
any_keywords: list[str] = field(default_factory=list)
none_keywords: list[str] = field(default_factory=list)
regex_patterns: list[str] = field(default_factory=list)
case_sensitive: bool = True
default: bool = False
FIELD_SPECS = {
"any": ConfigFieldSpec(
name="any",
display_name="Contains keywords",
type_hint="list[str]",
required=False,
description="Returns True if any keyword is matched.",
),
"none": ConfigFieldSpec(
name="none",
display_name="Exclude keywords",
type_hint="list[str]",
required=False,
description="If any of the excluded keywords are matched, return False (highest priority).",
),
"regex": ConfigFieldSpec(
name="regex",
display_name="Regular expressions",
type_hint="list[str]",
required=False,
description="Returns True if any regular expression is matched.",
advance=True,
),
"case_sensitive": ConfigFieldSpec(
name="case_sensitive",
display_name="case sensitive",
type_hint="bool",
required=False,
default=True,
description="Whether to distinguish between uppercase and lowercase letters (default is true).",
),
# "default": ConfigFieldSpec(
# name="default",
# display_name="Default Result",
# type_hint="bool",
# required=False,
# default=False,
# description="Return value when no condition matches; defaults to False",
# advance=True,
# ),
}
@classmethod
def from_dict(cls, data: Mapping[str, Any], *, path: str) -> "KeywordEdgeConditionConfig":
mapping = require_mapping(data, path)
any_keywords = _normalize_keyword_list(mapping.get("any", []), extend_path(path, "any"))
none_keywords = _normalize_keyword_list(mapping.get("none", []), extend_path(path, "none"))
regex_patterns = _normalize_keyword_list(mapping.get("regex", []), extend_path(path, "regex"))
case_sensitive = optional_bool(mapping, "case_sensitive", path, default=True)
default_value = optional_bool(mapping, "default", path, default=False)
if not (any_keywords or none_keywords or regex_patterns):
raise ConfigError("keyword condition requires any/none/regex", path)
return cls(
any_keywords=any_keywords,
none_keywords=none_keywords,
regex_patterns=regex_patterns,
case_sensitive=True if case_sensitive is None else bool(case_sensitive),
default=False if default_value is None else bool(default_value),
path=path,
)
def display_label(self) -> str:
return f"keyword(any={len(self.any_keywords)}, none={len(self.none_keywords)}, regex={len(self.regex_patterns)})"
def to_external_value(self) -> Any:
payload: Dict[str, Any] = {}
if self.any_keywords:
payload["any"] = list(self.any_keywords)
if self.none_keywords:
payload["none"] = list(self.none_keywords)
if self.regex_patterns:
payload["regex"] = list(self.regex_patterns)
payload["case_sensitive"] = self.case_sensitive
payload["default"] = self.default
return payload
TConditionConfig = TypeVar("TConditionConfig", bound=EdgeConditionTypeConfig)
@dataclass
class EdgeConditionConfig(BaseConfig):
"""Wrapper config that stores condition type + concrete config."""
type: str
config: EdgeConditionTypeConfig
FIELD_SPECS = {
"type": ConfigFieldSpec(
name="type",
display_name="Condition Type",
type_hint="str",
required=True,
description="Select which condition implementation to run (function, keyword, etc.) so the engine can resolve the schema.",
),
"config": ConfigFieldSpec(
name="config",
display_name="Condition Config",
type_hint="object",
required=True,
description="Payload interpreted by the chosen function or any/none/regex lists for keyword mode.",
),
}
@classmethod
def _normalize_value(cls, value: Any, path: str) -> Mapping[str, Any]:
if value is None:
return {"type": "function", "config": {"name": "true"}}
if isinstance(value, bool):
if value:
return {"type": "function", "config": {"name": "true"}}
return {"type": "function", "config": {"name": "always_false"}}
if isinstance(value, str):
return {"type": "function", "config": {"name": value}}
return require_mapping(value, path)
@classmethod
def from_dict(cls, data: Any, *, path: str) -> "EdgeConditionConfig":
mapping = cls._normalize_value(data, path)
condition_type = require_str(mapping, "type", path)
config_payload = mapping.get("config")
config_path = extend_path(path, "config")
try:
schema = get_edge_condition_schema(condition_type)
except SchemaLookupError as exc:
raise ConfigError(f"unknown condition type '{condition_type}'", extend_path(path, "type")) from exc
if config_payload is None:
raise ConfigError("condition config is required", config_path)
condition_config = schema.config_cls.from_dict(config_payload, path=config_path)
return cls(type=condition_type, config=condition_config, path=path)
@classmethod
def child_routes(cls) -> Dict[ChildKey, Type[BaseConfig]]:
return {
ChildKey(field="config", value=name): schema.config_cls
for name, schema in iter_edge_condition_schemas().items()
}
@classmethod
def field_specs(cls) -> Dict[str, ConfigFieldSpec]:
specs = super().field_specs()
type_spec = specs.get("type")
if type_spec:
registrations = iter_edge_condition_schemas()
names = list(registrations.keys())
descriptions = {name: schema.summary for name, schema in registrations.items()}
specs["type"] = replace(
type_spec,
enum=names,
enum_options=enum_options_from_values(names, descriptions, preserve_label_case=True),
)
return specs
def display_label(self) -> str:
return self.config.display_label()
def to_external_value(self) -> Any:
if self.type == "function":
return self.config.to_external_value()
return {
"type": self.type,
"config": self.config.to_external_value(),
}
def as_config(self, expected_type: Type[TConditionConfig]) -> TConditionConfig | None:
config = self.config
if isinstance(config, expected_type):
return cast(TConditionConfig, config)
return None
+334
View File
@@ -0,0 +1,334 @@
"""Edge payload processor configuration dataclasses."""
from dataclasses import dataclass, field, fields, replace
from typing import Any, Dict, Mapping, Type, TypeVar, cast
from entity.enum_options import enum_options_from_values
from utils.function_catalog import get_function_catalog
from utils.function_manager import EDGE_PROCESSOR_FUNCTION_DIR
from schema_registry import (
SchemaLookupError,
get_edge_processor_schema,
iter_edge_processor_schemas,
)
from entity.configs.base import (
BaseConfig,
ChildKey,
ConfigError,
ConfigFieldSpec,
ensure_list,
optional_bool,
optional_str,
require_mapping,
require_str,
extend_path,
)
def _serialize_config(config: BaseConfig) -> Dict[str, Any]:
payload: Dict[str, Any] = {}
for field_obj in fields(config):
if field_obj.name == "path":
continue
payload[field_obj.name] = getattr(config, field_obj.name)
return payload
class EdgeProcessorTypeConfig(BaseConfig):
"""Base helper class for payload processor configs."""
def display_label(self) -> str:
return self.__class__.__name__
def to_external_value(self) -> Any:
return _serialize_config(self)
_NO_MATCH_DESCRIPTIONS = {
"pass": "Leave the payload untouched when no match is found.",
"default": "Apply default_value (or empty string) if nothing matches.",
"drop": "Discard the payload entirely when the regex does not match.",
}
@dataclass
class RegexEdgeProcessorConfig(EdgeProcessorTypeConfig):
"""Configuration for regex-based payload extraction."""
pattern: str = ""
group: str | int | None = None
case_sensitive: bool = True
multiline: bool = False
dotall: bool = False
multiple: bool = False
template: str | None = None
on_no_match: str = "pass"
default_value: str | None = None
FIELD_SPECS = {
"pattern": ConfigFieldSpec(
name="pattern",
display_name="Regex Pattern",
type_hint="str",
required=True,
description="Python regular expression used to extract content.",
),
"group": ConfigFieldSpec(
name="group",
display_name="Capture Group",
type_hint="str",
required=False,
description="Capture group name or index. Defaults to the entire match.",
),
"case_sensitive": ConfigFieldSpec(
name="case_sensitive",
display_name="Case Sensitive",
type_hint="bool",
required=False,
default=True,
description="Whether the regex should be case sensitive.",
),
"multiline": ConfigFieldSpec(
name="multiline",
display_name="Multiline Flag",
type_hint="bool",
required=False,
default=False,
description="Enable multiline mode (re.MULTILINE).",
advance=True,
),
"dotall": ConfigFieldSpec(
name="dotall",
display_name="Dotall Flag",
type_hint="bool",
required=False,
default=False,
description="Enable dotall mode (re.DOTALL).",
advance=True,
),
"multiple": ConfigFieldSpec(
name="multiple",
display_name="Return Multiple Matches",
type_hint="bool",
required=False,
default=False,
description="Whether to collect all matches instead of only the first.",
advance=True,
),
"template": ConfigFieldSpec(
name="template",
display_name="Output Template",
type_hint="str",
required=False,
description="Optional template applied to the extracted value. Use '{match}' placeholder.",
advance=True,
),
"on_no_match": ConfigFieldSpec(
name="on_no_match",
display_name="No Match Behavior",
type_hint="enum",
required=False,
default="pass",
enum=["pass", "default", "drop"],
description="Behavior when no match is found.",
enum_options=enum_options_from_values(
list(_NO_MATCH_DESCRIPTIONS.keys()),
_NO_MATCH_DESCRIPTIONS,
preserve_label_case=True,
),
advance=True,
),
"default_value": ConfigFieldSpec(
name="default_value",
display_name="Default Value",
type_hint="str",
required=False,
description="Fallback content when on_no_match=default.",
advance=True,
),
}
@classmethod
def from_dict(cls, data: Mapping[str, Any], *, path: str) -> "RegexEdgeProcessorConfig":
mapping = require_mapping(data, path)
pattern = require_str(mapping, "pattern", path, allow_empty=False)
group_value = mapping.get("group")
group_normalized: str | int | None = None
if group_value is not None:
if isinstance(group_value, int):
group_normalized = group_value
elif isinstance(group_value, str):
if group_value.isdigit():
group_normalized = int(group_value)
else:
group_normalized = group_value
else:
raise ConfigError("group must be str or int", extend_path(path, "group"))
multiple = optional_bool(mapping, "multiple", path, default=False)
case_sensitive = optional_bool(mapping, "case_sensitive", path, default=True)
multiline = optional_bool(mapping, "multiline", path, default=False)
dotall = optional_bool(mapping, "dotall", path, default=False)
on_no_match = optional_str(mapping, "on_no_match", path) or "pass"
if on_no_match not in {"pass", "default", "drop"}:
raise ConfigError("on_no_match must be pass, default or drop", extend_path(path, "on_no_match"))
template = optional_str(mapping, "template", path)
default_value = optional_str(mapping, "default_value", path)
return cls(
pattern=pattern,
group=group_normalized,
case_sensitive=True if case_sensitive is None else bool(case_sensitive),
multiline=bool(multiline) if multiline is not None else False,
dotall=bool(dotall) if dotall is not None else False,
multiple=bool(multiple) if multiple is not None else False,
template=template,
on_no_match=on_no_match,
default_value=default_value,
path=path,
)
def display_label(self) -> str:
return f"regex({self.pattern})"
@dataclass
class FunctionEdgeProcessorConfig(EdgeProcessorTypeConfig):
"""Configuration for function-based payload processors."""
name: str = ""
FIELD_SPECS = {
"name": ConfigFieldSpec(
name="name",
display_name="Function Name",
type_hint="str",
required=True,
description="Name of the Python function located in functions/edge_processor.",
)
}
@classmethod
def field_specs(cls) -> Dict[str, ConfigFieldSpec]:
specs = super().field_specs()
name_spec = specs.get("name")
if not name_spec:
return specs
catalog = get_function_catalog(EDGE_PROCESSOR_FUNCTION_DIR)
names = catalog.list_function_names()
metadata = catalog.list_metadata()
description = name_spec.description or "Processor function name"
if catalog.load_error:
description = f"{description} (Loading failed: {catalog.load_error})"
elif not names:
description = f"{description} (No processor functions found in functions/edge_processor)"
descriptions = {}
for name in names:
meta = metadata.get(name)
descriptions[name] = (meta.description if meta else None) or "No description provided."
specs["name"] = replace(
name_spec,
enum=names or None,
enum_options=enum_options_from_values(names, descriptions, preserve_label_case=True) if names else None,
description=description,
)
return specs
@classmethod
def from_dict(cls, data: Mapping[str, Any], *, path: str) -> "FunctionEdgeProcessorConfig":
mapping = require_mapping(data, path)
name = require_str(mapping, "name", path, allow_empty=False)
return cls(name=name, path=path)
def display_label(self) -> str:
return self.name or "function"
def to_external_value(self) -> Any:
return {"name": self.name}
TProcessorConfig = TypeVar("TProcessorConfig", bound=EdgeProcessorTypeConfig)
@dataclass
class EdgeProcessorConfig(BaseConfig):
"""Wrapper config storing processor type and payload."""
type: str
config: EdgeProcessorTypeConfig
FIELD_SPECS = {
"type": ConfigFieldSpec(
name="type",
display_name="Processor Type",
type_hint="str",
required=True,
description="Select which processor implementation to use (regex_extract, function, etc.).",
),
"config": ConfigFieldSpec(
name="config",
display_name="Processor Config",
type_hint="object",
required=True,
description="Payload interpreted by the selected processor.",
),
}
@classmethod
def from_dict(cls, data: Any, *, path: str) -> "EdgeProcessorConfig":
if data is None:
raise ConfigError("processor configuration cannot be null", path)
mapping = require_mapping(data, path)
processor_type = require_str(mapping, "type", path)
config_payload = mapping.get("config")
if config_payload is None:
raise ConfigError("processor config is required", extend_path(path, "config"))
try:
schema = get_edge_processor_schema(processor_type)
except SchemaLookupError as exc:
raise ConfigError(f"unknown processor type '{processor_type}'", extend_path(path, "type")) from exc
processor_config = schema.config_cls.from_dict(config_payload, path=extend_path(path, "config"))
return cls(type=processor_type, config=processor_config, path=path)
@classmethod
def child_routes(cls) -> Dict[ChildKey, Type[BaseConfig]]:
return {
ChildKey(field="config", value=name): schema.config_cls
for name, schema in iter_edge_processor_schemas().items()
}
@classmethod
def field_specs(cls) -> Dict[str, ConfigFieldSpec]:
specs = super().field_specs()
type_spec = specs.get("type")
if type_spec:
registrations = iter_edge_processor_schemas()
names = list(registrations.keys())
descriptions = {name: schema.summary for name, schema in registrations.items()}
specs["type"] = replace(
type_spec,
enum=names,
enum_options=enum_options_from_values(names, descriptions, preserve_label_case=True),
)
return specs
def display_label(self) -> str:
return self.config.display_label()
def to_external_value(self) -> Any:
return {
"type": self.type,
"config": self.config.to_external_value(),
}
def as_config(self, expected_type: Type[TProcessorConfig]) -> TProcessorConfig | None:
config = self.config
if isinstance(config, expected_type):
return cast(TProcessorConfig, config)
return None
+313
View File
@@ -0,0 +1,313 @@
"""Graph-level configuration dataclasses."""
from dataclasses import dataclass, field
from collections import Counter
from typing import Any, Dict, List, Mapping
from entity.enums import LogLevel
from entity.enum_options import enum_options_for
from .base import (
BaseConfig,
ConfigError,
ConfigFieldSpec,
ensure_list,
optional_bool,
optional_dict,
optional_str,
require_mapping,
extend_path,
)
from .edge import EdgeConfig
from entity.configs.node.memory import MemoryStoreConfig
from entity.configs.node.agent import AgentConfig
from entity.configs.node.node import Node
@dataclass
class GraphDefinition(BaseConfig):
id: str | None
description: str | None
log_level: LogLevel
is_majority_voting: bool
nodes: List[Node] = field(default_factory=list)
edges: List[EdgeConfig] = field(default_factory=list)
memory: List[MemoryStoreConfig] | None = None
organization: str | None = None
initial_instruction: str | None = None
start_nodes: List[str] = field(default_factory=list)
end_nodes: List[str] | None = None
FIELD_SPECS = {
"id": ConfigFieldSpec(
name="id",
display_name="Graph ID",
type_hint="str",
required=True,
description="Graph identifier for referencing. Can only contain alphanumeric characters, underscores or hyphens, no spaces",
),
"description": ConfigFieldSpec(
name="description",
display_name="Graph Description",
type_hint="text",
required=False,
description="Human-readable narrative shown in UI/templates that explains the workflow goal, scope, and manual touchpoints.",
),
"log_level": ConfigFieldSpec(
name="log_level",
display_name="Log Level",
type_hint="enum:LogLevel",
required=False,
default=LogLevel.DEBUG.value,
enum=[lvl.value for lvl in LogLevel],
description="Runtime log level",
advance=True,
enum_options=enum_options_for(LogLevel),
),
"is_majority_voting": ConfigFieldSpec(
name="is_majority_voting",
display_name="Majority Voting Mode",
type_hint="bool",
required=False,
default=False,
description="Whether this is a majority voting graph",
advance=True,
),
"nodes": ConfigFieldSpec(
name="nodes",
display_name="Node List",
type_hint="list[Node]",
required=False,
description="Node list, must contain at least one node",
child=Node,
),
"edges": ConfigFieldSpec(
name="edges",
display_name="Edge List",
type_hint="list[EdgeConfig]",
required=False,
description="Directed edges between nodes",
child=EdgeConfig,
),
"memory": ConfigFieldSpec(
name="memory",
display_name="Memory Stores",
type_hint="list[MemoryStoreConfig]",
required=False,
description="Optional list of memory stores that nodes can reference through their model.memories attachments.",
child=MemoryStoreConfig,
),
# "organization": ConfigFieldSpec(
# name="organization",
# display_name="Organization Name",
# type_hint="str",
# required=False,
# description="Organization name",
# ),
"initial_instruction": ConfigFieldSpec(
name="initial_instruction",
display_name="Initial Instruction",
type_hint="text",
required=False,
description="Graph level initial instruction (for user)",
),
"start": ConfigFieldSpec(
name="start",
display_name="Start Node",
type_hint="list[str]",
required=False,
description="Start node ID list (entry list executed at workflow start; not recommended to edit manually)",
advance=True,
),
"end": ConfigFieldSpec(
name="end",
display_name="End Node",
type_hint="list[str]",
required=False,
description="End node ID list (used to collect final graph output, not part of execution logic). Commonly needed in subgraphs. This is an ordered list: earlier nodes are checked first; the first with output becomes the graph output, otherwise continue down the list.",
advance=True,
),
}
# CONSTRAINTS = (
# RuntimeConstraint(
# when={"memory": "*"},
# require=["memory"],
# message="After defining memory, at least one store must be declared",
# ),
# )
@classmethod
def from_dict(cls, data: Mapping[str, Any], *, path: str) -> "GraphDefinition":
mapping = require_mapping(data, path)
graph_id = optional_str(mapping, "id", path)
description = optional_str(mapping, "description", path)
if "vars" in mapping and mapping["vars"]:
raise ConfigError("vars are only supported at DesignConfig root", extend_path(path, "vars"))
log_level_raw = mapping.get("log_level", LogLevel.DEBUG.value)
try:
log_level = LogLevel(log_level_raw)
except ValueError as exc:
raise ConfigError(
f"log_level must be one of {[lvl.value for lvl in LogLevel]}", extend_path(path, "log_level")
) from exc
is_majority = optional_bool(mapping, "is_majority_voting", path, default=False)
organization = optional_str(mapping, "organization", path)
initial_instruction = optional_str(mapping, "initial_instruction", path)
nodes_raw = ensure_list(mapping.get("nodes"))
# if not nodes_raw:
# raise ConfigError("graph must define at least one node", extend_path(path, "nodes"))
nodes: List[Node] = []
for idx, node_dict in enumerate(nodes_raw):
nodes.append(Node.from_dict(node_dict, path=extend_path(path, f"nodes[{idx}]")))
edges_raw = ensure_list(mapping.get("edges"))
edges: List[EdgeConfig] = []
for idx, edge_dict in enumerate(edges_raw):
edges.append(EdgeConfig.from_dict(edge_dict, path=extend_path(path, f"edges[{idx}]")))
memory_cfg: List[MemoryStoreConfig] | None = None
if "memory" in mapping and mapping["memory"] is not None:
raw_stores = ensure_list(mapping.get("memory"))
stores: List[MemoryStoreConfig] = []
seen: set[str] = set()
for idx, item in enumerate(raw_stores):
store = MemoryStoreConfig.from_dict(item, path=extend_path(path, f"memory[{idx}]"))
if store.name in seen:
raise ConfigError(
f"duplicated memory store name '{store.name}'",
extend_path(path, f"memory[{idx}].name"),
)
seen.add(store.name)
stores.append(store)
memory_cfg = stores
start_nodes: List[str] = []
if "start" in mapping and mapping["start"] is not None:
start_value = mapping["start"]
if isinstance(start_value, str):
start_nodes = [start_value]
elif isinstance(start_value, list) and all(isinstance(item, str) for item in start_value):
seen = set()
start_nodes = []
for item in start_value:
if item not in seen:
seen.add(item)
start_nodes.append(item)
else:
raise ConfigError("start must be a string or list of strings if provided", extend_path(path, "start"))
end_nodes = None
if "end" in mapping and mapping["end"] is not None:
end_value = mapping["end"]
if isinstance(end_value, str):
end_nodes = [end_value]
elif isinstance(end_value, list) and all(isinstance(item, str) for item in end_value):
end_nodes = list(end_value)
else:
raise ConfigError("end must be a string or list of strings", extend_path(path, "end"))
definition = cls(
id=graph_id,
description=description,
log_level=log_level,
is_majority_voting=bool(is_majority) if is_majority is not None else False,
nodes=nodes,
edges=edges,
memory=memory_cfg,
organization=organization,
initial_instruction=initial_instruction,
start_nodes=start_nodes,
end_nodes=end_nodes,
path=path,
)
definition.validate()
return definition
def validate(self) -> None:
node_ids = [node.id for node in self.nodes]
counts = Counter(node_ids)
duplicates = [nid for nid, count in counts.items() if count > 1]
if duplicates:
dup_list = ", ".join(sorted(duplicates))
raise ConfigError(f"duplicate node ids detected: {dup_list}", extend_path(self.path, "nodes"))
node_set = set(node_ids)
for start_node in self.start_nodes:
if start_node not in node_set:
raise ConfigError(
f"start node '{start_node}' not defined in nodes",
extend_path(self.path, "start"),
)
for edge in self.edges:
if edge.source not in node_set:
raise ConfigError(
f"edge references unknown source node '{edge.source}'",
extend_path(self.path, f"edges->{edge.source}->{edge.target}"),
)
if edge.target not in node_set:
raise ConfigError(
f"edge references unknown target node '{edge.target}'",
extend_path(self.path, f"edges->{edge.source}->{edge.target}"),
)
store_names = {store.name for store in self.memory} if self.memory else set()
for node in self.nodes:
model = node.as_config(AgentConfig)
if model:
for attachment in model.memories:
if attachment.name not in store_names:
raise ConfigError(
f"memory reference '{attachment.name}' not defined in graph.memory",
attachment.path or extend_path(node.path, "config.memories"),
)
@dataclass
class DesignConfig(BaseConfig):
version: str
vars: Dict[str, Any]
graph: GraphDefinition
FIELD_SPECS = {
"version": ConfigFieldSpec(
name="version",
display_name="Configuration Version",
type_hint="str",
required=False,
default="0.0.0",
description="Configuration version number",
advance=True,
),
"vars": ConfigFieldSpec(
name="vars",
display_name="Global Variables",
type_hint="dict[str, Any]",
required=False,
default={},
description="Global variables that can be referenced via ${VAR}",
),
"graph": ConfigFieldSpec(
name="graph",
display_name="Graph Definition",
type_hint="GraphDefinition",
required=True,
description="Core graph definition",
child=GraphDefinition,
),
}
@classmethod
def from_dict(cls, data: Mapping[str, Any], *, path: str = "root") -> "DesignConfig":
mapping = require_mapping(data, path)
version = optional_str(mapping, "version", path) or "0.0.0"
vars_block = optional_dict(mapping, "vars", path) or {}
if "graph" not in mapping or mapping["graph"] is None:
raise ConfigError("graph section is required", extend_path(path, "graph"))
graph = GraphDefinition.from_dict(mapping["graph"], path=extend_path(path, "graph"))
return cls(version=version, vars=vars_block, graph=graph, path=path)
+22
View File
@@ -0,0 +1,22 @@
"""Node config conveniences."""
from .agent import AgentConfig, AgentRetryConfig
from .human import HumanConfig
from .subgraph import SubgraphConfig
from .passthrough import PassthroughConfig
from .python_runner import PythonRunnerConfig
from .skills import AgentSkillsConfig
from .node import Node
from .literal import LiteralNodeConfig
__all__ = [
"AgentConfig",
"AgentRetryConfig",
"AgentSkillsConfig",
"HumanConfig",
"SubgraphConfig",
"PassthroughConfig",
"PythonRunnerConfig",
"LiteralNodeConfig",
"Node",
]
+577
View File
@@ -0,0 +1,577 @@
"""Agent-specific configuration dataclasses."""
from dataclasses import dataclass, field, replace
from typing import Any, Dict, Iterable, List, Mapping, Sequence
try: # pragma: no cover - Python < 3.11 lacks BaseExceptionGroup
from builtins import BaseExceptionGroup as _BASE_EXCEPTION_GROUP_TYPE # type: ignore[attr-defined]
except ImportError: # pragma: no cover
_BASE_EXCEPTION_GROUP_TYPE = None # type: ignore[assignment]
from entity.enums import AgentInputMode
from schema_registry import iter_model_provider_schemas
from utils.strs import titleize
from entity.configs.base import (
BaseConfig,
ConfigError,
ConfigFieldSpec,
EnumOption,
optional_bool,
optional_dict,
optional_str,
require_mapping,
require_str,
extend_path,
)
from .memory import MemoryAttachmentConfig
from .skills import AgentSkillsConfig
from .thinking import ThinkingConfig
from entity.configs.node.tooling import ToolingConfig
DEFAULT_RETRYABLE_STATUS_CODES = [408, 409, 425, 429, 500, 502, 503, 504]
DEFAULT_RETRYABLE_EXCEPTION_TYPES = [
"RateLimitError",
"APITimeoutError",
"APIError",
"APIConnectionError",
"ServiceUnavailableError",
"TimeoutError",
"InternalServerError",
"RemoteProtocolError",
"TransportError",
"ConnectError",
"ConnectTimeout",
"ReadError",
"ReadTimeout",
]
DEFAULT_RETRYABLE_MESSAGE_SUBSTRINGS = [
"rate limit",
"temporarily unavailable",
"timeout",
"server disconnected",
"connection reset",
]
def _coerce_float(value: Any, *, field_path: str, minimum: float = 0.0) -> float:
if isinstance(value, (int, float)):
coerced = float(value)
else:
raise ConfigError("expected number", field_path)
if coerced < minimum:
raise ConfigError(f"value must be >= {minimum}", field_path)
return coerced
def _coerce_positive_int(value: Any, *, field_path: str, minimum: int = 1) -> int:
if isinstance(value, bool):
raise ConfigError("expected integer", field_path)
if isinstance(value, int):
coerced = value
else:
raise ConfigError("expected integer", field_path)
if coerced < minimum:
raise ConfigError(f"value must be >= {minimum}", field_path)
return coerced
def _coerce_str_list(value: Any, *, field_path: str) -> List[str]:
if value is None:
return []
if not isinstance(value, Sequence) or isinstance(value, (str, bytes)):
raise ConfigError("expected list of strings", field_path)
result: List[str] = []
for idx, item in enumerate(value):
if not isinstance(item, str):
raise ConfigError("expected list of strings", f"{field_path}[{idx}]")
result.append(item.strip())
return result
def _coerce_int_list(value: Any, *, field_path: str) -> List[int]:
if value is None:
return []
if not isinstance(value, Sequence) or isinstance(value, (str, bytes)):
raise ConfigError("expected list of integers", field_path)
ints: List[int] = []
for idx, item in enumerate(value):
if isinstance(item, bool) or not isinstance(item, int):
raise ConfigError("expected list of integers", f"{field_path}[{idx}]")
ints.append(item)
return ints
@dataclass
class AgentRetryConfig(BaseConfig):
enabled: bool = True
max_attempts: int = 5
min_wait_seconds: float = 1.0
max_wait_seconds: float = 6.0
retry_on_status_codes: List[int] = field(default_factory=lambda: list(DEFAULT_RETRYABLE_STATUS_CODES))
retry_on_exception_types: List[str] = field(default_factory=lambda: [name.lower() for name in DEFAULT_RETRYABLE_EXCEPTION_TYPES])
non_retry_exception_types: List[str] = field(default_factory=list)
retry_on_error_substrings: List[str] = field(default_factory=lambda: list(DEFAULT_RETRYABLE_MESSAGE_SUBSTRINGS))
FIELD_SPECS = {
"enabled": ConfigFieldSpec(
name="enabled",
display_name="Enable Retry",
type_hint="bool",
required=False,
default=True,
description="Toggle automatic retry for provider calls",
),
"max_attempts": ConfigFieldSpec(
name="max_attempts",
display_name="Max Attempts",
type_hint="int",
required=False,
default=5,
description="Maximum number of total attempts (initial call + retries)",
),
"min_wait_seconds": ConfigFieldSpec(
name="min_wait_seconds",
display_name="Min Wait Seconds",
type_hint="float",
required=False,
default=1.0,
description="Minimum backoff wait before retry",
advance=True,
),
"max_wait_seconds": ConfigFieldSpec(
name="max_wait_seconds",
display_name="Max Wait Seconds",
type_hint="float",
required=False,
default=6.0,
description="Maximum backoff wait before retry",
advance=True,
),
"retry_on_status_codes": ConfigFieldSpec(
name="retry_on_status_codes",
display_name="Retryable Status Codes",
type_hint="list[int]",
required=False,
description="HTTP status codes that should trigger a retry",
advance=True,
),
"retry_on_exception_types": ConfigFieldSpec(
name="retry_on_exception_types",
display_name="Retryable Exception Types",
type_hint="list[str]",
required=False,
description="Exception class names (case-insensitive) that should trigger retries",
advance=True,
),
"non_retry_exception_types": ConfigFieldSpec(
name="non_retry_exception_types",
display_name="Non-Retryable Exception Types",
type_hint="list[str]",
required=False,
description="Exception class names (case-insensitive) that should never retry",
advance=True,
),
"retry_on_error_substrings": ConfigFieldSpec(
name="retry_on_error_substrings",
display_name="Retryable Message Substrings",
type_hint="list[str]",
required=False,
description="Substring matches within exception messages that enable retry",
advance=True,
),
}
@classmethod
def from_dict(cls, data: Mapping[str, Any], *, path: str) -> "AgentRetryConfig":
mapping = require_mapping(data, path)
enabled = optional_bool(mapping, "enabled", path, default=True)
if enabled is None:
enabled = True
max_attempts = _coerce_positive_int(mapping.get("max_attempts", 5), field_path=extend_path(path, "max_attempts"))
min_wait = _coerce_float(mapping.get("min_wait_seconds", 1.0), field_path=extend_path(path, "min_wait_seconds"), minimum=0.0)
max_wait = _coerce_float(mapping.get("max_wait_seconds", 6.0), field_path=extend_path(path, "max_wait_seconds"), minimum=0.0)
if max_wait < min_wait:
raise ConfigError("max_wait_seconds must be >= min_wait_seconds", extend_path(path, "max_wait_seconds"))
status_codes = mapping.get("retry_on_status_codes")
if status_codes is None:
retry_status_codes = list(DEFAULT_RETRYABLE_STATUS_CODES)
else:
retry_status_codes = _coerce_int_list(status_codes, field_path=extend_path(path, "retry_on_status_codes"))
retry_types_raw = mapping.get("retry_on_exception_types")
if retry_types_raw is None:
retry_types = [name.lower() for name in DEFAULT_RETRYABLE_EXCEPTION_TYPES]
else:
retry_types = [value.lower() for value in _coerce_str_list(retry_types_raw, field_path=extend_path(path, "retry_on_exception_types")) if value]
non_retry_types = [value.lower() for value in _coerce_str_list(mapping.get("non_retry_exception_types"), field_path=extend_path(path, "non_retry_exception_types")) if value]
retry_substrings_raw = mapping.get("retry_on_error_substrings")
if retry_substrings_raw is None:
retry_substrings = list(DEFAULT_RETRYABLE_MESSAGE_SUBSTRINGS)
else:
retry_substrings = [
value.lower()
for value in _coerce_str_list(
retry_substrings_raw,
field_path=extend_path(path, "retry_on_error_substrings"),
)
if value
]
return cls(
enabled=enabled,
max_attempts=max_attempts,
min_wait_seconds=min_wait,
max_wait_seconds=max_wait,
retry_on_status_codes=retry_status_codes,
retry_on_exception_types=retry_types,
non_retry_exception_types=non_retry_types,
retry_on_error_substrings=retry_substrings,
path=path,
)
@property
def is_active(self) -> bool:
return self.enabled and self.max_attempts > 1
def should_retry(self, exc: BaseException) -> bool:
if not self.is_active:
return False
chain: List[tuple[BaseException, set[str], int | None, str]] = []
for error in self._iter_exception_chain(exc):
chain.append(
(
error,
self._exception_name_set(error),
self._extract_status_code(error),
str(error).lower(),
)
)
if self.non_retry_exception_types:
for _, names, _, _ in chain:
if any(name in names for name in self.non_retry_exception_types):
return False
if self.retry_on_exception_types:
for _, names, _, _ in chain:
if any(name in names for name in self.retry_on_exception_types):
return True
if self.retry_on_status_codes:
for _, _, status_code, _ in chain:
if status_code is not None and status_code in self.retry_on_status_codes:
return True
if self.retry_on_error_substrings:
for _, _, _, message in chain:
if message and any(substr in message for substr in self.retry_on_error_substrings):
return True
return False
def _exception_name_set(self, exc: BaseException) -> set[str]:
names: set[str] = set()
for cls in exc.__class__.mro():
names.add(cls.__name__.lower())
names.add(f"{cls.__module__}.{cls.__name__}".lower())
return names
def _extract_status_code(self, exc: BaseException) -> int | None:
for attr in ("status_code", "http_status", "status", "statusCode"):
value = getattr(exc, attr, None)
if isinstance(value, int):
return value
response = getattr(exc, "response", None)
if response is not None:
for attr in ("status_code", "status", "statusCode"):
value = getattr(response, attr, None)
if isinstance(value, int):
return value
return None
def _iter_exception_chain(self, exc: BaseException) -> Iterable[BaseException]:
seen: set[int] = set()
stack: List[BaseException] = [exc]
while stack:
current = stack.pop()
if id(current) in seen:
continue
seen.add(id(current))
yield current
linked: List[BaseException] = []
cause = getattr(current, "__cause__", None)
context = getattr(current, "__context__", None)
if isinstance(cause, BaseException):
linked.append(cause)
if isinstance(context, BaseException):
linked.append(context)
if _BASE_EXCEPTION_GROUP_TYPE is not None and isinstance(current, _BASE_EXCEPTION_GROUP_TYPE):
for exc_item in getattr(current, "exceptions", None) or ():
if isinstance(exc_item, BaseException):
linked.append(exc_item)
stack.extend(linked)
@dataclass
class AgentConfig(BaseConfig):
provider: str
base_url: str
name: str
role: str | None = None
api_key: str | None = None
params: Dict[str, Any] = field(default_factory=dict)
retry: AgentRetryConfig | None = None
input_mode: AgentInputMode = AgentInputMode.MESSAGES
tooling: List[ToolingConfig] = field(default_factory=list)
thinking: ThinkingConfig | None = None
memories: List[MemoryAttachmentConfig] = field(default_factory=list)
skills: AgentSkillsConfig | None = None
# Runtime attributes (attached dynamically)
token_tracker: Any | None = field(default=None, init=False, repr=False)
node_id: str | None = field(default=None, init=False, repr=False)
@classmethod
def from_dict(cls, data: Mapping[str, Any], *, path: str) -> "AgentConfig":
mapping = require_mapping(data, path)
provider = require_str(mapping, "provider", path)
base_url = optional_str(mapping, "base_url", path)
name_value = mapping.get("name")
if isinstance(name_value, str) and name_value.strip():
model_name = name_value.strip()
else:
raise ConfigError("model.name must be a non-empty string", extend_path(path, "name"))
role = optional_str(mapping, "role", path)
api_key = optional_str(mapping, "api_key", path)
params = optional_dict(mapping, "params", path) or {}
raw_input_mode = optional_str(mapping, "input_mode", path)
input_mode = AgentInputMode.MESSAGES
if raw_input_mode:
try:
input_mode = AgentInputMode(raw_input_mode.strip().lower())
except ValueError as exc:
raise ConfigError(
"model.input_mode must be 'prompt' or 'messages'",
extend_path(path, "input_mode"),
) from exc
tooling_cfg: List[ToolingConfig] = []
if "tooling" in mapping and mapping["tooling"] is not None:
raw_tooling = mapping["tooling"]
if not isinstance(raw_tooling, list):
raise ConfigError("tooling must be a list", extend_path(path, "tooling"))
for idx, item in enumerate(raw_tooling):
tooling_cfg.append(
ToolingConfig.from_dict(item, path=extend_path(path, f"tooling[{idx}]"))
)
thinking_cfg = None
if "thinking" in mapping and mapping["thinking"] is not None:
thinking_cfg = ThinkingConfig.from_dict(mapping["thinking"], path=extend_path(path, "thinking"))
memories_cfg: List[MemoryAttachmentConfig] = []
if "memories" in mapping and mapping["memories"] is not None:
raw_memories = mapping["memories"]
if not isinstance(raw_memories, list):
raise ConfigError("memories must be a list", extend_path(path, "memories"))
for idx, item in enumerate(raw_memories):
memories_cfg.append(
MemoryAttachmentConfig.from_dict(item, path=extend_path(path, f"memories[{idx}]"))
)
retry_cfg = None
if "retry" in mapping and mapping["retry"] is not None:
retry_cfg = AgentRetryConfig.from_dict(mapping["retry"], path=extend_path(path, "retry"))
skills_cfg = None
if "skills" in mapping and mapping["skills"] is not None:
skills_cfg = AgentSkillsConfig.from_dict(mapping["skills"], path=extend_path(path, "skills"))
return cls(
provider=provider,
base_url=base_url,
name=model_name,
role=role,
api_key=api_key,
params=params,
tooling=tooling_cfg,
thinking=thinking_cfg,
memories=memories_cfg,
skills=skills_cfg,
retry=retry_cfg,
input_mode=input_mode,
path=path,
)
FIELD_SPECS = {
"name": ConfigFieldSpec(
name="name",
display_name="Model Name",
type_hint="str",
required=True,
description="Specific model name e.g. gpt-4o",
),
"role": ConfigFieldSpec(
name="role",
display_name="System Prompt",
type_hint="text",
required=False,
description="Model system prompt",
),
"provider": ConfigFieldSpec(
name="provider",
display_name="Model Provider",
type_hint="str",
required=True,
description="Name of a registered provider (openai, gemini, etc.) that selects the underlying client adapter.",
default="openai",
),
"base_url": ConfigFieldSpec(
name="base_url",
display_name="Base URL",
type_hint="str",
required=False,
description="Override the provider's default endpoint; leave empty to use the built-in base URL.",
advance=True,
default="${BASE_URL}",
),
"api_key": ConfigFieldSpec(
name="api_key",
display_name="API Key",
type_hint="str",
required=False,
description="Credential consumed by the provider client; reference an env var such as ${API_KEY} that matches the selected provider.",
advance=True,
default="${API_KEY}",
),
"params": ConfigFieldSpec(
name="params",
display_name="Call Parameters",
type_hint="dict[str, Any]",
required=False,
default={},
description="Call parameters (temperature, top_p, etc.)",
advance=True,
),
# "input_mode": ConfigFieldSpec( # currently, many features depend on messages mode, so hide this and force messages
# name="input_mode",
# display_name="Input Mode",
# type_hint="enum:AgentInputMode",
# required=False,
# default=AgentInputMode.MESSAGES.value,
# description="Model input mode: messages (default) or prompt",
# enum=[item.value for item in AgentInputMode],
# advance=True,
# enum_options=enum_options_for(AgentInputMode),
# ),
"tooling": ConfigFieldSpec(
name="tooling",
display_name="Tool Configuration",
type_hint="list[ToolingConfig]",
required=False,
description="Bound tool configuration list",
child=ToolingConfig,
advance=True,
),
"thinking": ConfigFieldSpec(
name="thinking",
display_name="Thinking Configuration",
type_hint="ThinkingConfig",
required=False,
description="Thinking process configuration",
child=ThinkingConfig,
advance=True,
),
"memories": ConfigFieldSpec(
name="memories",
display_name="Memory Attachments",
type_hint="list[MemoryAttachmentConfig]",
required=False,
description="Associated memory references",
child=MemoryAttachmentConfig,
advance=True,
),
"skills": ConfigFieldSpec(
name="skills",
display_name="Agent Skills",
type_hint="AgentSkillsConfig",
required=False,
description="Agent Skills allowlist and built-in skill activation/file-read tools.",
child=AgentSkillsConfig,
advance=True,
),
"retry": ConfigFieldSpec(
name="retry",
display_name="Retry Policy",
type_hint="AgentRetryConfig",
required=False,
description="Automatic retry policy for this model",
child=AgentRetryConfig,
advance=True,
),
}
@classmethod
def field_specs(cls) -> Dict[str, ConfigFieldSpec]:
specs = super().field_specs()
provider_spec = specs.get("provider")
if provider_spec:
enum_spec = cls._apply_provider_enum(provider_spec)
specs["provider"] = enum_spec
return specs
@staticmethod
def _apply_provider_enum(provider_spec: ConfigFieldSpec) -> ConfigFieldSpec:
provider_names, metadata = AgentConfig._provider_registry_snapshot()
if not provider_names:
return provider_spec
enum_options: List[EnumOption] = []
for name in provider_names:
meta = metadata.get(name) or {}
label = meta.get("label") or titleize(name)
enum_options.append(
EnumOption(
value=name,
label=label,
description=meta.get("summary"),
)
)
default_value = provider_spec.default
if not default_value or default_value not in provider_names:
default_value = AgentConfig._preferred_provider_default(provider_names)
return replace(
provider_spec,
enum=provider_names,
enum_options=enum_options,
default=default_value,
)
@staticmethod
def _preferred_provider_default(provider_names: List[str]) -> str:
if "openai" in provider_names:
return "openai"
return provider_names[0]
@staticmethod
def _provider_registry_snapshot() -> tuple[List[str], Dict[str, Dict[str, Any]]]:
specs = iter_model_provider_schemas()
names = list(specs.keys())
metadata: Dict[str, Dict[str, Any]] = {}
for name, spec in specs.items():
metadata[name] = {
"label": spec.label,
"summary": spec.summary,
**(spec.metadata or {}),
}
return names, metadata
+29
View File
@@ -0,0 +1,29 @@
"""Human node configuration."""
from dataclasses import dataclass
from typing import Any, Mapping
from entity.configs.base import BaseConfig, ConfigFieldSpec, optional_str, require_mapping
@dataclass
class HumanConfig(BaseConfig):
description: str | None = None
@classmethod
def from_dict(cls, data: Mapping[str, Any] | None, *, path: str) -> "HumanConfig":
if data is None:
return cls(description=None, path=path)
mapping = require_mapping(data, path)
description = optional_str(mapping, "description", path)
return cls(description=description, path=path)
FIELD_SPECS = {
"description": ConfigFieldSpec(
name="description",
display_name="Human Task Description",
type_hint="text",
required=False,
description="Description of the task for human to complete",
)
}
+69
View File
@@ -0,0 +1,69 @@
"""Configuration for literal nodes."""
from dataclasses import dataclass
from typing import Mapping, Any
from entity.configs.base import (
BaseConfig,
ConfigError,
ConfigFieldSpec,
EnumOption,
optional_str,
require_mapping,
require_str,
)
from entity.messages import MessageRole
@dataclass
class LiteralNodeConfig(BaseConfig):
"""Config describing the literal payload emitted by the node."""
content: str = ""
role: MessageRole = MessageRole.USER
@classmethod
def from_dict(cls, data: Mapping[str, Any], *, path: str) -> "LiteralNodeConfig":
mapping = require_mapping(data, path)
content = require_str(mapping, "content", path)
if not content:
raise ConfigError("content cannot be empty", f"{path}.content")
role_value = optional_str(mapping, "role", path)
role = MessageRole.USER
if role_value:
normalized = role_value.strip().lower()
if normalized not in (MessageRole.USER.value, MessageRole.ASSISTANT.value):
raise ConfigError("role must be 'user' or 'assistant'", f"{path}.role")
role = MessageRole(normalized)
return cls(content=content, role=role, path=path)
def validate(self) -> None:
if not self.content:
raise ConfigError("content cannot be empty", f"{self.path}.content")
if self.role not in (MessageRole.USER, MessageRole.ASSISTANT):
raise ConfigError("role must be 'user' or 'assistant'", f"{self.path}.role")
FIELD_SPECS = {
"content": ConfigFieldSpec(
name="content",
display_name="Literal Content",
type_hint="text",
required=True,
description="Plain text emitted whenever the node executes.",
),
"role": ConfigFieldSpec(
name="role",
display_name="Message Role",
type_hint="str",
required=False,
default=MessageRole.USER.value,
enum=[MessageRole.USER.value, MessageRole.ASSISTANT.value],
enum_options=[
EnumOption(value=MessageRole.USER.value, label="user"),
EnumOption(value=MessageRole.ASSISTANT.value, label="assistant"),
],
description="Select whether the literal message should appear as a user or assistant entry.",
),
}
+79
View File
@@ -0,0 +1,79 @@
"""Configuration for loop counter guard nodes."""
from dataclasses import dataclass
from typing import Mapping, Any, Optional
from entity.configs.base import (
BaseConfig,
ConfigError,
ConfigFieldSpec,
require_mapping,
extend_path,
optional_str,
)
@dataclass
class LoopCounterConfig(BaseConfig):
"""Configuration schema for the loop counter node type."""
max_iterations: int = 10
reset_on_emit: bool = True
message: Optional[str] = None
@classmethod
def from_dict(cls, data: Mapping[str, Any] | None, *, path: str) -> "LoopCounterConfig":
mapping = require_mapping(data or {}, path)
max_iterations_raw = mapping.get("max_iterations", 10)
try:
max_iterations = int(max_iterations_raw)
except (TypeError, ValueError) as exc: # pragma: no cover - defensive
raise ConfigError(
"max_iterations must be an integer",
extend_path(path, "max_iterations"),
) from exc
if max_iterations < 1:
raise ConfigError("max_iterations must be >= 1", extend_path(path, "max_iterations"))
reset_on_emit = bool(mapping.get("reset_on_emit", True))
message = optional_str(mapping, "message", path)
return cls(
max_iterations=max_iterations,
reset_on_emit=reset_on_emit,
message=message,
path=path,
)
def validate(self) -> None:
if self.max_iterations < 1:
raise ConfigError("max_iterations must be >= 1", extend_path(self.path, "max_iterations"))
FIELD_SPECS = {
"max_iterations": ConfigFieldSpec(
name="max_iterations",
display_name="Maximum Iterations",
type_hint="int",
required=True,
default=10,
description="How many times the loop can run before this node emits an output.",
),
"reset_on_emit": ConfigFieldSpec(
name="reset_on_emit",
display_name="Reset After Emit",
type_hint="bool",
required=False,
default=True,
description="Whether to reset the internal counter after reaching the limit.",
advance=True,
),
"message": ConfigFieldSpec(
name="message",
display_name="Release Message",
type_hint="text",
required=False,
description="Optional text sent downstream once the iteration cap is reached.",
advance=True,
),
}
+133
View File
@@ -0,0 +1,133 @@
"""Configuration for loop timer guard nodes."""
from dataclasses import dataclass
from typing import Mapping, Any, Optional
from entity.configs.base import (
BaseConfig,
ConfigError,
ConfigFieldSpec,
EnumOption,
require_mapping,
extend_path,
optional_str,
)
@dataclass
class LoopTimerConfig(BaseConfig):
"""Configuration schema for the loop timer node type."""
max_duration: float = 60.0
duration_unit: str = "seconds"
reset_on_emit: bool = True
message: Optional[str] = None
passthrough: bool = False
@classmethod
def from_dict(
cls, data: Mapping[str, Any] | None, *, path: str
) -> "LoopTimerConfig":
mapping = require_mapping(data or {}, path)
max_duration_raw = mapping.get("max_duration", 60.0)
try:
max_duration = float(max_duration_raw)
except (TypeError, ValueError) as exc: # pragma: no cover - defensive
raise ConfigError(
"max_duration must be a number",
extend_path(path, "max_duration"),
) from exc
if max_duration <= 0:
raise ConfigError(
"max_duration must be > 0", extend_path(path, "max_duration")
)
duration_unit = str(mapping.get("duration_unit", "seconds"))
valid_units = ["seconds", "minutes", "hours"]
if duration_unit not in valid_units:
raise ConfigError(
f"duration_unit must be one of: {', '.join(valid_units)}",
extend_path(path, "duration_unit"),
)
reset_on_emit = bool(mapping.get("reset_on_emit", True))
message = optional_str(mapping, "message", path)
passthrough = bool(mapping.get("passthrough", False))
return cls(
max_duration=max_duration,
duration_unit=duration_unit,
reset_on_emit=reset_on_emit,
message=message,
passthrough=passthrough,
path=path,
)
def validate(self) -> None:
if self.max_duration <= 0:
raise ConfigError(
"max_duration must be > 0", extend_path(self.path, "max_duration")
)
valid_units = ["seconds", "minutes", "hours"]
if self.duration_unit not in valid_units:
raise ConfigError(
f"duration_unit must be one of: {', '.join(valid_units)}",
extend_path(self.path, "duration_unit"),
)
FIELD_SPECS = {
"max_duration": ConfigFieldSpec(
name="max_duration",
display_name="Maximum Duration",
type_hint="float",
required=True,
default=60.0,
description="How long the loop can run before this node emits an output.",
),
"duration_unit": ConfigFieldSpec(
name="duration_unit",
display_name="Duration Unit",
type_hint="str",
required=True,
default="seconds",
description="Unit of time for max_duration: 'seconds', 'minutes', or 'hours'.",
enum=["seconds", "minutes", "hours"],
enum_options=[
EnumOption(
value="seconds", label="Seconds", description="Time in seconds"
),
EnumOption(
value="minutes", label="Minutes", description="Time in minutes"
),
EnumOption(value="hours", label="Hours", description="Time in hours"),
],
),
"reset_on_emit": ConfigFieldSpec(
name="reset_on_emit",
display_name="Reset After Emit",
type_hint="bool",
required=False,
default=True,
description="Whether to reset the internal timer after reaching the limit.",
advance=True,
),
"message": ConfigFieldSpec(
name="message",
display_name="Release Message",
type_hint="text",
required=False,
description="Optional text sent downstream once the time limit is reached.",
advance=True,
),
"passthrough": ConfigFieldSpec(
name="passthrough",
display_name="Passthrough Mode",
type_hint="bool",
required=False,
default=False,
description="If true, after emitting the limit message, all subsequent inputs pass through unchanged.",
advance=True,
),
}
+533
View File
@@ -0,0 +1,533 @@
"""Memory-related configuration dataclasses."""
from dataclasses import dataclass, field, replace
from typing import Any, Dict, List, Mapping
from entity.enums import AgentExecFlowStage
from entity.enum_options import enum_options_for, enum_options_from_values
from schema_registry import (
SchemaLookupError,
get_memory_store_schema,
iter_memory_store_schemas,
)
from entity.configs.base import (
BaseConfig,
ConfigError,
ConfigFieldSpec,
ChildKey,
ensure_list,
optional_dict,
optional_str,
require_mapping,
require_str,
extend_path,
)
@dataclass
class EmbeddingConfig(BaseConfig):
provider: str
model: str
api_key: str | None = None
base_url: str | None = None
params: Dict[str, Any] = field(default_factory=dict)
@classmethod
def from_dict(cls, data: Mapping[str, Any], *, path: str) -> "EmbeddingConfig":
mapping = require_mapping(data, path)
provider = require_str(mapping, "provider", path)
model = require_str(mapping, "model", path)
api_key = optional_str(mapping, "api_key", path)
base_url = optional_str(mapping, "base_url", path)
params = optional_dict(mapping, "params", path) or {}
return cls(provider=provider, model=model, api_key=api_key, base_url=base_url, params=params, path=path)
FIELD_SPECS = {
"provider": ConfigFieldSpec(
name="provider",
display_name="Embedding Provider",
type_hint="str",
required=True,
default="openai",
description="Embedding provider",
),
"model": ConfigFieldSpec(
name="model",
display_name="Embedding Model",
type_hint="str",
required=True,
default="text-embedding-3-small",
description="Embedding model name",
),
"api_key": ConfigFieldSpec(
name="api_key",
display_name="API Key",
type_hint="str",
required=False,
description="API key",
default="${API_KEY}",
advance=True,
),
"base_url": ConfigFieldSpec(
name="base_url",
display_name="Base URL",
type_hint="str",
required=False,
description="Custom Base URL",
default="${BASE_URL}",
advance=True,
),
"params": ConfigFieldSpec(
name="params",
display_name="Custom Parameters",
type_hint="dict[str, Any]",
required=False,
default={},
description="Embedding parameters (temperature, etc.)",
advance=True,
),
}
@dataclass
class FileSourceConfig(BaseConfig):
source_path: str
file_types: List[str] | None = None
recursive: bool = True
encoding: str = "utf-8"
@classmethod
def from_dict(cls, data: Mapping[str, Any], *, path: str) -> "FileSourceConfig":
mapping = require_mapping(data, path)
file_path = require_str(mapping, "path", path)
file_types_value = mapping.get("file_types")
file_types: List[str] | None = None
if file_types_value is not None:
items = ensure_list(file_types_value)
normalized: List[str] = []
for idx, item in enumerate(items):
if not isinstance(item, str):
raise ConfigError("file_types entries must be strings", extend_path(path, f"file_types[{idx}]") )
normalized.append(item)
file_types = normalized
recursive_value = mapping.get("recursive", True)
if not isinstance(recursive_value, bool):
raise ConfigError("recursive must be boolean", extend_path(path, "recursive"))
encoding = optional_str(mapping, "encoding", path) or "utf-8"
return cls(source_path=file_path, file_types=file_types, recursive=recursive_value, encoding=encoding, path=path)
FIELD_SPECS = {
"path": ConfigFieldSpec(
name="path",
display_name="File/Directory Path",
type_hint="str",
required=True,
description="Path to file/directory to be indexed",
),
"file_types": ConfigFieldSpec(
name="file_types",
display_name="File Type Filter",
type_hint="list[str]",
required=False,
description="List of file type suffixes to limit (e.g. .md, .txt)",
),
"recursive": ConfigFieldSpec(
name="recursive",
display_name="Recursive Subdirectories",
type_hint="bool",
required=False,
default=True,
description="Whether to include subdirectories recursively",
advance=True,
),
"encoding": ConfigFieldSpec(
name="encoding",
display_name="File Encoding",
type_hint="str",
required=False,
default="utf-8",
description="Encoding used to read files",
advance=True,
),
}
@dataclass
class SimpleMemoryConfig(BaseConfig):
memory_path: str | None = None
embedding: EmbeddingConfig | None = None
@classmethod
def from_dict(cls, data: Mapping[str, Any], *, path: str) -> "SimpleMemoryConfig":
mapping = require_mapping(data, path)
memory_path = optional_str(mapping, "memory_path", path)
embedding_cfg = None
if "embedding" in mapping and mapping["embedding"] is not None:
embedding_cfg = EmbeddingConfig.from_dict(mapping["embedding"], path=extend_path(path, "embedding"))
return cls(memory_path=memory_path, embedding=embedding_cfg, path=path)
FIELD_SPECS = {
"memory_path": ConfigFieldSpec(
name="memory_path",
display_name="Memory File Path",
type_hint="str",
required=False,
description="Simple memory file path",
advance=True,
),
"embedding": ConfigFieldSpec(
name="embedding",
display_name="Embedding Configuration",
type_hint="EmbeddingConfig",
required=False,
description="Optional embedding configuration",
child=EmbeddingConfig,
),
}
@dataclass
class FileMemoryConfig(BaseConfig):
index_path: str | None = None
file_sources: List[FileSourceConfig] = field(default_factory=list)
embedding: EmbeddingConfig | None = None
@classmethod
def from_dict(cls, data: Mapping[str, Any], *, path: str) -> "FileMemoryConfig":
mapping = require_mapping(data, path)
sources_raw = ensure_list(mapping.get("file_sources"))
if not sources_raw:
raise ConfigError("file_sources must contain at least one entry", extend_path(path, "file_sources"))
sources: List[FileSourceConfig] = []
for idx, item in enumerate(sources_raw):
sources.append(FileSourceConfig.from_dict(item, path=extend_path(path, f"file_sources[{idx}]")))
index_path = optional_str(mapping, "index_path", path)
if index_path is None:
index_path = optional_str(mapping, "memory_path", path)
embedding_cfg = None
if "embedding" in mapping and mapping["embedding"] is not None:
embedding_cfg = EmbeddingConfig.from_dict(mapping["embedding"], path=extend_path(path, "embedding"))
return cls(index_path=index_path, file_sources=sources, embedding=embedding_cfg, path=path)
FIELD_SPECS = {
"index_path": ConfigFieldSpec(
name="index_path",
display_name="Index Path",
type_hint="str",
required=False,
description="Vector index storage path",
advance=True,
),
"file_sources": ConfigFieldSpec(
name="file_sources",
display_name="File Source List",
type_hint="list[FileSourceConfig]",
required=True,
description="List of file sources to ingest",
child=FileSourceConfig,
),
"embedding": ConfigFieldSpec(
name="embedding",
display_name="Embedding Configuration",
type_hint="EmbeddingConfig",
required=False,
description="Embedding used for file memory",
child=EmbeddingConfig,
),
}
@dataclass
class BlackboardMemoryConfig(BaseConfig):
memory_path: str | None = None
max_items: int = 1000
@classmethod
def from_dict(cls, data: Mapping[str, Any], *, path: str) -> "BlackboardMemoryConfig":
mapping = require_mapping(data, path)
memory_path = optional_str(mapping, "memory_path", path)
max_items_value = mapping.get("max_items", 1000)
if not isinstance(max_items_value, int) or max_items_value <= 0:
raise ConfigError("max_items must be a positive integer", extend_path(path, "max_items"))
return cls(memory_path=memory_path, max_items=max_items_value, path=path)
FIELD_SPECS = {
"memory_path": ConfigFieldSpec(
name="memory_path",
display_name="Blackboard Path",
type_hint="str",
required=False,
description="JSON path for blackboard memory writing. Pass 'auto' to auto-create in working directory, valid for this run only",
default="auto",
advance=True,
),
"max_items": ConfigFieldSpec(
name="max_items",
display_name="Maximum Items",
type_hint="int",
required=False,
default=1000,
description="Maximum number of memory items to retain (trimmed by time)",
advance=True,
),
}
@dataclass
class Mem0MemoryConfig(BaseConfig):
"""Configuration for Mem0 managed memory service."""
api_key: str = ""
org_id: str | None = None
project_id: str | None = None
user_id: str | None = None
agent_id: str | None = None
@classmethod
def from_dict(cls, data: Mapping[str, Any], *, path: str) -> "Mem0MemoryConfig":
mapping = require_mapping(data, path)
api_key = require_str(mapping, "api_key", path)
org_id = optional_str(mapping, "org_id", path)
project_id = optional_str(mapping, "project_id", path)
user_id = optional_str(mapping, "user_id", path)
agent_id = optional_str(mapping, "agent_id", path)
return cls(
api_key=api_key,
org_id=org_id,
project_id=project_id,
user_id=user_id,
agent_id=agent_id,
path=path,
)
FIELD_SPECS = {
"api_key": ConfigFieldSpec(
name="api_key",
display_name="Mem0 API Key",
type_hint="str",
required=True,
description="Mem0 API key (get one from app.mem0.ai)",
default="${MEM0_API_KEY}",
),
"org_id": ConfigFieldSpec(
name="org_id",
display_name="Organization ID",
type_hint="str",
required=False,
description="Mem0 organization ID for scoping",
advance=True,
),
"project_id": ConfigFieldSpec(
name="project_id",
display_name="Project ID",
type_hint="str",
required=False,
description="Mem0 project ID for scoping",
advance=True,
),
"user_id": ConfigFieldSpec(
name="user_id",
display_name="User ID",
type_hint="str",
required=False,
description="User ID for user-scoped memories. Mutually exclusive with agent_id in API calls.",
),
"agent_id": ConfigFieldSpec(
name="agent_id",
display_name="Agent ID",
type_hint="str",
required=False,
description="Agent ID for agent-scoped memories. Mutually exclusive with user_id in API calls.",
),
}
@dataclass
class MemoryStoreConfig(BaseConfig):
name: str
type: str
config: BaseConfig | None = None
@classmethod
def from_dict(cls, data: Mapping[str, Any], *, path: str) -> "MemoryStoreConfig":
mapping = require_mapping(data, path)
name = require_str(mapping, "name", path)
store_type = require_str(mapping, "type", path)
try:
schema = get_memory_store_schema(store_type)
except SchemaLookupError as exc:
raise ConfigError(f"unsupported memory store type '{store_type}'", extend_path(path, "type")) from exc
if "config" not in mapping or mapping["config"] is None:
raise ConfigError("memory store requires config block", extend_path(path, "config"))
config_obj = schema.config_cls.from_dict(mapping["config"], path=extend_path(path, "config"))
return cls(name=name, type=store_type, config=config_obj, path=path)
def require_payload(self) -> BaseConfig:
if not self.config:
raise ConfigError("memory store payload missing", extend_path(self.path, "config"))
return self.config
FIELD_SPECS = {
"name": ConfigFieldSpec(
name="name",
display_name="Store Name",
type_hint="str",
required=True,
description="Unique name of the memory store",
),
"type": ConfigFieldSpec(
name="type",
display_name="Store Type",
type_hint="str",
required=True,
description="Memory store type",
),
"config": ConfigFieldSpec(
name="config",
display_name="Store Configuration",
type_hint="object",
required=True,
description="Schema required by the selected store type (simple/file/blackboard/etc.), following that type's required keys.",
),
}
@classmethod
def child_routes(cls) -> Dict[ChildKey, type[BaseConfig]]:
return {
ChildKey(field="config", value=name): schema.config_cls
for name, schema in iter_memory_store_schemas().items()
}
@classmethod
def field_specs(cls) -> Dict[str, ConfigFieldSpec]:
specs = super().field_specs()
type_spec = specs.get("type")
if type_spec:
registrations = iter_memory_store_schemas()
names = list(registrations.keys())
descriptions = {name: schema.summary for name, schema in registrations.items()}
specs["type"] = replace(
type_spec,
enum=names,
enum_options=enum_options_from_values(names, descriptions, preserve_label_case=True),
)
return specs
@dataclass
class MemoryAttachmentConfig(BaseConfig):
name: str
retrieve_stage: List[AgentExecFlowStage] | None = None
top_k: int = 3
similarity_threshold: float = -1.0
read: bool = True
write: bool = True
@classmethod
def from_dict(cls, data: Mapping[str, Any], *, path: str) -> "MemoryAttachmentConfig":
mapping = require_mapping(data, path)
name = require_str(mapping, "name", path)
stages_raw = mapping.get("retrieve_stage")
stages: List[AgentExecFlowStage] | None = None
if stages_raw is not None:
stage_list = ensure_list(stages_raw)
parsed: List[AgentExecFlowStage] = []
for idx, item in enumerate(stage_list):
try:
parsed.append(AgentExecFlowStage(item))
except ValueError as exc:
raise ConfigError(
f"retrieve_stage entries must be one of {[stage.value for stage in AgentExecFlowStage]}",
extend_path(path, f"retrieve_stage[{idx}]"),
) from exc
stages = parsed
top_k_value = mapping.get("top_k", 3)
if not isinstance(top_k_value, int) or top_k_value <= 0:
raise ConfigError("top_k must be a positive integer", extend_path(path, "top_k"))
threshold_value = mapping.get("similarity_threshold", -1.0)
if not isinstance(threshold_value, (int, float)):
raise ConfigError("similarity_threshold must be numeric", extend_path(path, "similarity_threshold"))
read_value = mapping.get("read", True)
if not isinstance(read_value, bool):
raise ConfigError("read must be boolean", extend_path(path, "read"))
write_value = mapping.get("write", True)
if not isinstance(write_value, bool):
raise ConfigError("write must be boolean", extend_path(path, "write"))
return cls(
name=name,
retrieve_stage=stages,
top_k=top_k_value,
similarity_threshold=float(threshold_value),
read=read_value,
write=write_value,
path=path,
)
FIELD_SPECS = {
"name": ConfigFieldSpec(
name="name",
display_name="Memory Name",
type_hint="str",
required=True,
description="Name of the referenced memory store",
),
"retrieve_stage": ConfigFieldSpec(
name="retrieve_stage",
display_name="Retrieve Stage",
type_hint="list[AgentExecFlowStage]",
required=False,
description="Execution stages when memory retrieval occurs. If not set, defaults to all stages. NOTE: this config is related to thinking, if the thinking module is not used, this config has only effect on `gen` stage.",
enum=[stage.value for stage in AgentExecFlowStage],
enum_options=enum_options_for(AgentExecFlowStage),
),
"top_k": ConfigFieldSpec(
name="top_k",
display_name="Top K",
type_hint="int",
required=False,
default=3,
description="Number of items to retrieve",
advance=True,
),
"similarity_threshold": ConfigFieldSpec(
name="similarity_threshold",
display_name="Similarity Threshold",
type_hint="float",
required=False,
default=-1.0,
description="Similarity threshold (-1 means no similarity threshold filter)",
advance=True,
),
"read": ConfigFieldSpec(
name="read",
display_name="Allow Read",
type_hint="bool",
required=False,
default=True,
description="Whether to read this memory during execution",
advance=True,
),
"write": ConfigFieldSpec(
name="write",
display_name="Allow Write",
type_hint="bool",
required=False,
default=True,
description="Whether to write back to this memory after execution",
advance=True,
),
}
+471
View File
@@ -0,0 +1,471 @@
"""Node configuration dataclasses."""
from dataclasses import dataclass, field, replace
from typing import Any, Dict, Iterable, List, Mapping, Optional, Tuple
from entity.messages import Message, MessageRole
from schema_registry import (
SchemaLookupError,
get_node_schema,
iter_node_schemas,
)
from entity.configs.base import (
BaseConfig,
ConfigError,
ConfigFieldSpec,
EnumOption,
ChildKey,
ensure_list,
optional_str,
require_mapping,
require_str,
extend_path,
)
from entity.configs.edge.edge_condition import EdgeConditionConfig
from entity.configs.edge.edge_processor import EdgeProcessorConfig
from entity.configs.edge.dynamic_edge_config import DynamicEdgeConfig
from entity.configs.node.agent import AgentConfig
from entity.configs.node.human import HumanConfig
from entity.configs.node.tooling import FunctionToolConfig
NodePayload = Message
@dataclass
class EdgeLink:
target: "Node"
config: Dict[str, Any] = field(default_factory=dict)
trigger: bool = True
condition: str = "true"
condition_config: EdgeConditionConfig | None = None
condition_type: str | None = None
condition_metadata: Dict[str, Any] = field(default_factory=dict)
triggered: bool = False
carry_data: bool = True
keep_message: bool = False
clear_context: bool = False
clear_kept_context: bool = False
condition_manager: Any = None
process_config: EdgeProcessorConfig | None = None
process_type: str | None = None
process_metadata: Dict[str, Any] = field(default_factory=dict)
payload_processor: Any = None
dynamic_config: DynamicEdgeConfig | None = None
def __post_init__(self) -> None:
self.config = dict(self.config or {})
@dataclass
class Node(BaseConfig):
id: str
type: str
description: str | None = None
# keep_context: bool = False
log_output: bool = True
context_window: int = 0
vars: Dict[str, Any] = field(default_factory=dict)
config: BaseConfig | None = None
# dynamic configuration has been moved to edges (DynamicEdgeConfig)
input: List[Message] = field(default_factory=list)
output: List[NodePayload] = field(default_factory=list)
# Runtime flag for explicit graph start nodes
start_triggered: bool = False
predecessors: List["Node"] = field(default_factory=list, repr=False)
successors: List["Node"] = field(default_factory=list, repr=False)
_outgoing_edges: List[EdgeLink] = field(default_factory=list, repr=False)
FIELD_SPECS = {
"id": ConfigFieldSpec(
name="id",
display_name="Node ID",
type_hint="str",
required=True,
description="Unique node identifier",
),
"type": ConfigFieldSpec(
name="type",
display_name="Node Type",
type_hint="str",
required=True,
description="Select a node type registered in node.registry (agent, human, python_runner, etc.); it determines the config schema.",
),
"description": ConfigFieldSpec(
name="description",
display_name="Node Description",
type_hint="str",
required=False,
advance=True,
description="Short summary shown in consoles/logs to explain this node's role or prompt context.",
),
# "keep_context": ConfigFieldSpec(
# name="keep_context",
# display_name="Preserve Context",
# type_hint="bool",
# required=False,
# default=False,
# description="Nodes clear their context by default; set to True to keep context data after execution.",
# ),
"context_window": ConfigFieldSpec(
name="context_window",
display_name="Context Window Size",
type_hint="int",
required=False,
default=0,
description="Number of context messages accessible during node execution. 0 means clear all context except messages with keep_message=True, -1 means unlimited, other values represent the number of context messages to keep besides those with keep_message=True.",
# advance=True,
),
"log_output": ConfigFieldSpec(
name="log_output",
display_name="Log Output",
type_hint="bool",
required=False,
default=True,
advance=True,
description="Whether to log this node's output content. Set to false to avoid logging outputs.",
),
"config": ConfigFieldSpec(
name="config",
display_name="Node Configuration",
type_hint="object",
required=True,
description="Configuration object required by the chosen node type (see Schema API for the supported fields).",
),
# Dynamic execution configuration has been moved to edges (DynamicEdgeConfig)
}
@classmethod
def child_routes(cls) -> Dict[ChildKey, type[BaseConfig]]:
routes: Dict[ChildKey, type[BaseConfig]] = {}
for name, schema in iter_node_schemas().items():
routes[ChildKey(field="config", value=name)] = schema.config_cls
return routes
@classmethod
def field_specs(cls) -> Dict[str, ConfigFieldSpec]:
specs = super().field_specs()
type_spec = specs.get("type")
if type_spec:
registrations = iter_node_schemas()
specs["type"] = replace(
type_spec,
enum=list(registrations.keys()),
enum_options=[
EnumOption(
value=name,
label=name,
description=schema.summary or "No description provided for this node type",
)
for name, schema in registrations.items()
],
)
return specs
@classmethod
def from_dict(cls, data: Mapping[str, Any], *, path: str) -> "Node":
mapping = require_mapping(data, path)
node_id = require_str(mapping, "id", path)
node_type = require_str(mapping, "type", path)
try:
schema = get_node_schema(node_type)
except SchemaLookupError as exc:
raise ConfigError(
f"unsupported node type '{node_type}'",
extend_path(path, "type"),
) from exc
description = optional_str(mapping, "description", path)
# keep_context = bool(mapping.get("keep_context", False))
log_output = bool(mapping.get("log_output", True))
context_window = int(mapping.get("context_window", 0))
input_value = ensure_list(mapping.get("input"))
output_value = ensure_list(mapping.get("output"))
input_messages: List[Message] = []
for value in input_value:
if isinstance(value, dict) and "role" in value:
input_messages.append(Message.from_dict(value))
elif isinstance(value, Message):
input_messages.append(value)
else:
input_messages.append(Message(role=MessageRole.USER, content=str(value)))
if "config" not in mapping or mapping["config"] is None:
raise ConfigError("node config block required", extend_path(path, "config"))
config_obj = schema.config_cls.from_dict(
mapping["config"], path=extend_path(path, "config")
)
formatted_output: List[NodePayload] = []
for value in output_value:
if isinstance(value, dict) and "role" in value:
formatted_output.append(Message.from_dict(value))
elif isinstance(value, Message):
formatted_output.append(value)
else:
formatted_output.append(
Message(role=MessageRole.ASSISTANT, content=str(value))
)
# Dynamic configuration parsing removed - dynamic is now on edges
node = cls(
id=node_id,
type=node_type,
description=description,
log_output=log_output,
input=input_messages,
output=formatted_output,
# keep_context=keep_context,
context_window=context_window,
vars={},
config=config_obj,
path=path,
)
node.validate()
return node
def append_input(self, message: Message) -> None:
self.input.append(message)
def append_output(self, payload: NodePayload) -> None:
self.output.append(payload)
def clear_input(self, *, preserve_kept: bool = False, context_window: int = 0) -> int:
"""Clear queued inputs according to the node's context window semantics."""
if not preserve_kept:
self.input = []
return len(self.input)
if context_window < 0:
return len(self.input)
if context_window == 0:
self.input = [message for message in self.input if getattr(message, "keep", False)]
return len(self.input)
# context_window > 0 => retain the newest messages up to the specified
# capacity, but never drop messages flagged with keep=True. Those kept
# messages still count toward the window, effectively consuming slots that
# would otherwise be available for non-kept inputs.
keep_count = sum(1 for message in self.input if getattr(message, "keep", False))
allowed_non_keep = max(0, context_window - keep_count)
non_keep_total = sum(1 for message in self.input if not getattr(message, "keep", False))
non_keep_to_drop = max(0, non_keep_total - allowed_non_keep)
trimmed_inputs: List[Message] = []
for message in self.input:
if getattr(message, "keep", False):
trimmed_inputs.append(message)
continue
if non_keep_to_drop > 0:
non_keep_to_drop -= 1
continue
trimmed_inputs.append(message)
self.input = trimmed_inputs
return len(self.input)
def clear_inputs_by_flag(self, *, drop_non_keep: bool, drop_keep: bool) -> Tuple[int, int]:
"""Clear queued inputs according to keep markers."""
if not drop_non_keep and not drop_keep:
return 0, 0
remaining: List[Message] = []
removed_non_keep = 0
removed_keep = 0
for message in self.input:
is_keep = message.keep
if is_keep and drop_keep:
removed_keep += 1
continue
if not is_keep and drop_non_keep:
removed_non_keep += 1
continue
remaining.append(message)
if removed_non_keep or removed_keep:
self.input = remaining
return removed_non_keep, removed_keep
def validate(self) -> None:
if not self.config:
raise ConfigError("node configuration missing", extend_path(self.path, "config"))
if hasattr(self.config, "validate"):
self.config.validate()
@property
def node_type(self) -> str:
return self.type
@property
def model_name(self) -> Optional[str]:
agent = self.as_config(AgentConfig)
if not agent:
return None
return agent.name
@property
def role(self) -> Optional[str]:
agent = self.as_config(AgentConfig)
if agent:
return agent.role
human = self.as_config(HumanConfig)
if human:
return human.description
return None
@property
def tools(self) -> List[Any]:
agent = self.as_config(AgentConfig)
if agent and agent.tooling:
all_tools: List[Any] = []
for tool_config in agent.tooling:
func_cfg = tool_config.as_config(FunctionToolConfig)
if func_cfg:
all_tools.extend(func_cfg.tools)
return all_tools
return []
@property
def memories(self) -> List[Any]:
agent = self.as_config(AgentConfig)
if agent:
return list(agent.memories)
return []
@property
def params(self) -> Dict[str, Any]:
agent = self.as_config(AgentConfig)
if agent:
return dict(agent.params)
return {}
@property
def base_url(self) -> Optional[str]:
agent = self.as_config(AgentConfig)
if agent:
return agent.base_url
return None
def add_successor(self, node: "Node", edge_config: Optional[Dict[str, Any]] = None) -> None:
if node not in self.successors:
self.successors.append(node)
payload = dict(edge_config or {})
existing = next((link for link in self._outgoing_edges if link.target is node), None)
trigger = bool(payload.get("trigger", True)) if payload else True
carry_data = bool(payload.get("carry_data", True)) if payload else True
keep_message = bool(payload.get("keep_message", False)) if payload else False
clear_context = bool(payload.get("clear_context", False)) if payload else False
clear_kept_context = bool(payload.get("clear_kept_context", False)) if payload else False
condition_config = payload.pop("condition_config", None)
if not isinstance(condition_config, EdgeConditionConfig):
raw_value = payload.get("condition", "true")
condition_config = EdgeConditionConfig.from_dict(
raw_value,
path=extend_path(self.path, f"edge[{self.id}->{node.id}].condition"),
)
condition_label = condition_config.display_label()
condition_type = condition_config.type
condition_serializable = condition_config.to_external_value()
process_config = payload.pop("process_config", None)
if process_config is None and payload.get("process") is not None:
process_config = EdgeProcessorConfig.from_dict(
payload.get("process"),
path=extend_path(self.path, f"edge[{self.id}->{node.id}].process"),
)
process_serializable = process_config.to_external_value() if isinstance(process_config, EdgeProcessorConfig) else None
process_type = process_config.type if isinstance(process_config, EdgeProcessorConfig) else None
process_label = process_config.display_label() if isinstance(process_config, EdgeProcessorConfig) else None
# Handle dynamic_config
dynamic_config = payload.pop("dynamic_config", None)
if dynamic_config is None and payload.get("dynamic") is not None:
dynamic_config = DynamicEdgeConfig.from_dict(
payload.get("dynamic"),
path=extend_path(self.path, f"edge[{self.id}->{node.id}].dynamic"),
)
payload["condition"] = condition_serializable
payload["condition_label"] = condition_label
payload["condition_type"] = condition_type
if process_serializable is not None:
payload["process"] = process_serializable
payload["process_label"] = process_label
payload["process_type"] = process_type
if existing:
existing.config.update(payload)
existing.trigger = trigger
existing.condition = condition_label
existing.condition_config = condition_config
existing.condition_type = condition_type
existing.carry_data = carry_data
existing.keep_message = keep_message
existing.clear_context = clear_context
existing.clear_kept_context = clear_kept_context
if isinstance(process_config, EdgeProcessorConfig):
existing.process_config = process_config
existing.process_type = process_type
else:
existing.process_config = None
existing.process_type = None
existing.dynamic_config = dynamic_config
else:
self._outgoing_edges.append(
EdgeLink(
target=node,
config=payload,
trigger=trigger,
condition=condition_label,
condition_config=condition_config,
condition_type=condition_type,
carry_data=carry_data,
keep_message=keep_message,
clear_context=clear_context,
clear_kept_context=clear_kept_context,
process_config=process_config if isinstance(process_config, EdgeProcessorConfig) else None,
process_type=process_type,
dynamic_config=dynamic_config,
)
)
def add_predecessor(self, node: "Node") -> None:
if node not in self.predecessors:
self.predecessors.append(node)
def iter_outgoing_edges(self) -> Iterable[EdgeLink]:
return tuple(self._outgoing_edges)
def find_outgoing_edge(self, node_id: str) -> EdgeLink | None:
for link in self._outgoing_edges:
if link.target.id == node_id:
return link
return None
def is_triggered(self) -> bool:
if self.start_triggered:
return True
for predecessor in self.predecessors:
for edge_link in predecessor.iter_outgoing_edges():
if edge_link.target is self and edge_link.trigger and edge_link.triggered:
return True
return False
def reset_triggers(self) -> None:
self.start_triggered = False
for predecessor in self.predecessors:
for edge_link in predecessor.iter_outgoing_edges():
if edge_link.target is self:
edge_link.triggered = False
def merge_vars(self, parent_vars: Optional[Dict[str, Any]] = None) -> Dict[str, Any]:
merged = dict(parent_vars or {})
merged.update(self.vars)
return merged
+32
View File
@@ -0,0 +1,32 @@
"""Configuration for passthrough nodes."""
from dataclasses import dataclass
from typing import Mapping, Any
from entity.configs.base import BaseConfig, ConfigFieldSpec, optional_bool, require_mapping
@dataclass
class PassthroughConfig(BaseConfig):
"""Configuration for passthrough nodes."""
only_last_message: bool = True
@classmethod
def from_dict(cls, data: Mapping[str, Any] | None, *, path: str) -> "PassthroughConfig":
if data is None:
return cls(only_last_message=True, path=path)
mapping = require_mapping(data, path)
only_last_message = optional_bool(mapping, "only_last_message", path, default=True)
return cls(only_last_message=only_last_message, path=path)
FIELD_SPECS = {
"only_last_message": ConfigFieldSpec(
name="only_last_message",
display_name="Only Last Message",
type_hint="bool",
required=False,
default=True,
description="If True, only pass the last received message. If False, pass all messages.",
),
}
+97
View File
@@ -0,0 +1,97 @@
"""Configuration for Python code execution nodes."""
import sys
from dataclasses import dataclass, field
from typing import Any, Dict, List, Mapping
from entity.configs.base import (
BaseConfig,
ConfigError,
ConfigFieldSpec,
ensure_list,
optional_dict,
optional_str,
require_mapping,
)
def _default_interpreter() -> str:
return sys.executable or "python3"
@dataclass
class PythonRunnerConfig(BaseConfig):
interpreter: str = field(default_factory=_default_interpreter)
args: List[str] = field(default_factory=list)
env: Dict[str, str] = field(default_factory=dict)
timeout_seconds: int = 60
encoding: str = "utf-8"
@classmethod
def from_dict(cls, data: Mapping[str, Any], *, path: str) -> "PythonRunnerConfig":
mapping = require_mapping(data, path)
interpreter = optional_str(mapping, "interpreter", path) or _default_interpreter()
args_raw = mapping.get("args")
args = [str(item) for item in ensure_list(args_raw)] if args_raw is not None else []
env = optional_dict(mapping, "env", path) or {}
timeout_value = mapping.get("timeout_seconds", 60)
if not isinstance(timeout_value, int) or timeout_value <= 0:
raise ConfigError("timeout_seconds must be a positive integer", f"{path}.timeout_seconds")
encoding = optional_str(mapping, "encoding", path) or "utf-8"
if not encoding:
raise ConfigError("encoding cannot be empty", f"{path}.encoding")
return cls(
interpreter=interpreter,
args=args,
env={str(key): str(value) for key, value in env.items()},
timeout_seconds=timeout_value,
encoding=encoding,
path=path,
)
FIELD_SPECS = {
"interpreter": ConfigFieldSpec(
name="interpreter",
display_name="Python Path",
type_hint="str",
required=False,
default=_default_interpreter(),
description="Python executable file path, defaults to current process interpreter",
advance=True,
),
"args": ConfigFieldSpec(
name="args",
display_name="Startup Parameters",
type_hint="list[str]",
required=False,
default=[],
description="Parameter list appended after interpreter",
advance=True,
),
"env": ConfigFieldSpec(
name="env",
display_name="Additional Environment Variables",
type_hint="dict[str, str]",
required=False,
default={},
description="Additional environment variables, will override process defaults",
advance=True,
),
"timeout_seconds": ConfigFieldSpec(
name="timeout_seconds",
display_name="Timeout (seconds)",
type_hint="int",
required=False,
default=60,
description="Script execution timeout (seconds)",
),
"encoding": ConfigFieldSpec(
name="encoding",
display_name="Output Encoding",
type_hint="str",
required=False,
default="utf-8",
description="Encoding used to parse stdout/stderr",
advance=True,
),
}
+176
View File
@@ -0,0 +1,176 @@
"""Agent skill configuration models."""
from dataclasses import dataclass, field, replace
from pathlib import Path
from typing import Any, Dict, List, Mapping
import yaml
from entity.configs.base import (
BaseConfig,
ConfigError,
ConfigFieldSpec,
EnumOption,
optional_bool,
extend_path,
require_mapping,
)
REPO_ROOT = Path(__file__).resolve().parents[3]
DEFAULT_SKILLS_ROOT = (REPO_ROOT / ".agents" / "skills").resolve()
def _discover_default_skills() -> List[tuple[str, str]]:
if not DEFAULT_SKILLS_ROOT.exists() or not DEFAULT_SKILLS_ROOT.is_dir():
return []
discovered: List[tuple[str, str]] = []
for candidate in sorted(DEFAULT_SKILLS_ROOT.iterdir()):
if not candidate.is_dir():
continue
skill_file = candidate / "SKILL.md"
if not skill_file.is_file():
continue
try:
frontmatter = _parse_frontmatter(skill_file)
except Exception:
continue
raw_name = frontmatter.get("name")
raw_description = frontmatter.get("description")
if not isinstance(raw_name, str) or not raw_name.strip():
continue
if not isinstance(raw_description, str) or not raw_description.strip():
continue
discovered.append((raw_name.strip(), raw_description.strip()))
return discovered
def _parse_frontmatter(skill_file: Path) -> Mapping[str, object]:
text = skill_file.read_text(encoding="utf-8")
if not text.startswith("---"):
raise ValueError("missing frontmatter")
lines = text.splitlines()
end_idx = None
for idx in range(1, len(lines)):
if lines[idx].strip() == "---":
end_idx = idx
break
if end_idx is None:
raise ValueError("missing closing delimiter")
payload = "\n".join(lines[1:end_idx])
data = yaml.safe_load(payload) or {}
if not isinstance(data, Mapping):
raise ValueError("frontmatter must be a mapping")
return data
@dataclass
class AgentSkillSelectionConfig(BaseConfig):
name: str
FIELD_SPECS = {
"name": ConfigFieldSpec(
name="name",
display_name="Skill Name",
type_hint="str",
required=True,
description="Discovered skill name from the default repo-level skills directory.",
),
}
@classmethod
def from_dict(cls, data: Mapping[str, Any], *, path: str) -> "AgentSkillSelectionConfig":
mapping = require_mapping(data, path)
name = mapping.get("name")
if not isinstance(name, str) or not name.strip():
raise ConfigError("skill name is required", extend_path(path, "name"))
return cls(name=name.strip(), path=path)
@classmethod
def field_specs(cls) -> Dict[str, ConfigFieldSpec]:
specs = super().field_specs()
name_spec = specs.get("name")
if name_spec is None:
return specs
discovered = _discover_default_skills()
enum_values = [name for name, _ in discovered] or None
enum_options = [
EnumOption(value=name, label=name, description=description)
for name, description in discovered
] or None
description = name_spec.description or "Skill name"
if not discovered:
description = (
f"{description} (no skills found in {DEFAULT_SKILLS_ROOT})"
)
else:
description = (
f"{description} Picker options come from {DEFAULT_SKILLS_ROOT}."
)
specs["name"] = replace(
name_spec,
enum=enum_values,
enum_options=enum_options,
description=description,
)
return specs
@dataclass
class AgentSkillsConfig(BaseConfig):
enabled: bool = False
allow: List[str] = field(default_factory=list)
FIELD_SPECS = {
"enabled": ConfigFieldSpec(
name="enabled",
display_name="Enable Skills",
type_hint="bool",
required=False,
default=False,
description="Enable Agent Skills discovery and the built-in skill tools for this agent.",
advance=True,
),
"allow": ConfigFieldSpec(
name="allow",
display_name="Allowed Skills",
type_hint="list[AgentSkillSelectionConfig]",
required=False,
description="Optional allowlist of discovered skill names. Leave empty to expose every discovered skill.",
child=AgentSkillSelectionConfig,
advance=True,
),
}
@classmethod
def from_dict(cls, data: Mapping[str, Any], *, path: str) -> "AgentSkillsConfig":
mapping = require_mapping(data, path)
enabled = optional_bool(mapping, "enabled", path, default=False)
if enabled is None:
enabled = False
allow = cls._coerce_allow_entries(mapping.get("allow"), field_path=extend_path(path, "allow"))
return cls(enabled=enabled, allow=allow, path=path)
@staticmethod
def _coerce_allow_entries(value: Any, *, field_path: str) -> List[str]:
if value is None:
return []
if not isinstance(value, list):
raise ConfigError("expected list of skill entries", field_path)
result: List[str] = []
for idx, item in enumerate(value):
item_path = f"{field_path}[{idx}]"
if isinstance(item, str):
normalized = item.strip()
if normalized:
result.append(normalized)
continue
if isinstance(item, Mapping):
entry = AgentSkillSelectionConfig.from_dict(item, path=item_path)
result.append(entry.name)
continue
raise ConfigError("expected skill entry mapping or string", item_path)
return result
+266
View File
@@ -0,0 +1,266 @@
"""Subgraph node configuration and registry helpers."""
from dataclasses import dataclass, replace
from typing import Any, Dict, Mapping
from entity.enums import LogLevel
from entity.enum_options import enum_options_for, enum_options_from_values
from entity.configs.base import (
BaseConfig,
ConfigError,
ConfigFieldSpec,
ChildKey,
require_mapping,
require_str,
extend_path,
)
from entity.configs.edge.edge import EdgeConfig
from entity.configs.node.memory import MemoryStoreConfig
from utils.registry import Registry, RegistryError
subgraph_source_registry = Registry("subgraph_source")
def register_subgraph_source(
name: str,
*,
config_cls: type[BaseConfig],
description: str | None = None,
) -> None:
"""Register a subgraph source configuration class."""
metadata = {"summary": description} if description else None
subgraph_source_registry.register(name, target=config_cls, metadata=metadata)
def get_subgraph_source_config(name: str) -> type[BaseConfig]:
entry = subgraph_source_registry.get(name)
config_cls = entry.load()
if not isinstance(config_cls, type) or not issubclass(config_cls, BaseConfig):
raise RegistryError(f"Entry '{name}' is not a BaseConfig subclass")
return config_cls
def iter_subgraph_source_registrations() -> Dict[str, type[BaseConfig]]:
return {name: entry.load() for name, entry in subgraph_source_registry.items()}
def iter_subgraph_source_metadata() -> Dict[str, Dict[str, Any]]:
return {name: dict(entry.metadata or {}) for name, entry in subgraph_source_registry.items()}
@dataclass
class SubgraphFileConfig(BaseConfig):
file_path: str
@classmethod
def from_dict(cls, data: Mapping[str, Any], *, path: str) -> "SubgraphFileConfig":
mapping = require_mapping(data, path)
file_path = require_str(mapping, "path", path)
return cls(file_path=file_path, path=path)
FIELD_SPECS = {
"path": ConfigFieldSpec(
name="path",
display_name="Subgraph File Path",
type_hint="str",
required=True,
description="Subgraph file path (relative to yaml_instance/ or absolute path)",
),
}
@dataclass
class SubgraphInlineConfig(BaseConfig):
graph: Dict[str, Any]
@classmethod
def from_dict(cls, data: Mapping[str, Any], *, path: str) -> "SubgraphInlineConfig":
mapping = require_mapping(data, path)
return cls(graph=dict(mapping), path=path)
def validate(self) -> None:
if "nodes" not in self.graph:
raise ConfigError("subgraph config must define nodes", extend_path(self.path, "nodes"))
if "edges" not in self.graph:
raise ConfigError("subgraph config must define edges", extend_path(self.path, "edges"))
FIELD_SPECS = {
"id": ConfigFieldSpec(
name="id",
display_name="Subgraph ID",
type_hint="str",
required=True,
description="Subgraph identifier",
),
"description": ConfigFieldSpec(
name="description",
display_name="Subgraph Description",
type_hint="str",
required=False,
description="Describe the subgraph's responsibility, trigger conditions, and success criteria so reviewers know when to call it.",
),
"log_level": ConfigFieldSpec(
name="log_level",
display_name="Log Level",
type_hint="enum:LogLevel",
required=False,
default=LogLevel.INFO.value,
enum=[lvl.value for lvl in LogLevel],
description="Subgraph runtime log level",
enum_options=enum_options_for(LogLevel),
),
"is_majority_voting": ConfigFieldSpec(
name="is_majority_voting",
display_name="Majority Voting",
type_hint="bool",
required=False,
default=False,
description="Whether to perform majority voting on node results",
),
"nodes": ConfigFieldSpec(
name="nodes",
display_name="Node List",
type_hint="list[Node]",
required=True,
description="Subgraph node list, must contain at least one node",
),
"edges": ConfigFieldSpec(
name="edges",
display_name="Edge List",
type_hint="list[EdgeConfig]",
required=True,
description="Subgraph edge list",
child=EdgeConfig,
),
"memory": ConfigFieldSpec(
name="memory",
display_name="Memory Stores",
type_hint="list[MemoryStoreConfig]",
required=False,
description="Provide a list of memory stores if this subgraph needs dedicated stores; leave empty to inherit parent graph stores.",
child=MemoryStoreConfig,
),
"vars": ConfigFieldSpec(
name="vars",
display_name="Variables",
type_hint="dict[str, Any]",
required=False,
default={},
description="Variables passed to subgraph nodes",
),
"organization": ConfigFieldSpec(
name="organization",
display_name="Organization",
type_hint="str",
required=False,
description="Subgraph organization/team identifier",
),
"initial_instruction": ConfigFieldSpec(
name="initial_instruction",
display_name="Initial Instruction",
type_hint="str",
required=False,
description="Subgraph level initial instruction",
),
"start": ConfigFieldSpec(
name="start",
display_name="Start Node",
type_hint="str | list[str]",
required=False,
description="Start node ID list (entry list executed at subgraph start; not recommended to edit manually)",
),
"end": ConfigFieldSpec(
name="end",
display_name="End Node",
type_hint="str | list[str]",
required=False,
description="End node ID list (used to collect final subgraph output, not part of execution logic). This is an ordered list: earlier nodes are checked first; the first with output becomes the subgraph output, otherwise continue down the list.",
),
}
@classmethod
def field_specs(cls) -> Dict[str, ConfigFieldSpec]:
specs = super().field_specs()
nodes_spec = specs.get("nodes")
if nodes_spec:
from entity.configs.node.node import Node
specs["nodes"] = replace(nodes_spec, child=Node)
return specs
@dataclass
class SubgraphConfig(BaseConfig):
type: str
config: BaseConfig | None = None
@classmethod
def from_dict(cls, data: Mapping[str, Any], *, path: str) -> "SubgraphConfig":
mapping = require_mapping(data, path)
source_type = require_str(mapping, "type", path)
if "vars" in mapping and mapping["vars"]:
raise ConfigError("vars is only allowed at root level (DesignConfig.vars)", extend_path(path, "vars"))
if "config" not in mapping or mapping["config"] is None:
raise ConfigError("subgraph configuration requires 'config' block", extend_path(path, "config"))
try:
config_cls = get_subgraph_source_config(source_type)
except RegistryError as exc:
raise ConfigError(
f"subgraph.type must be one of {list(iter_subgraph_source_registrations().keys())}",
extend_path(path, "type"),
) from exc
config_obj = config_cls.from_dict(mapping["config"], path=extend_path(path, "config"))
return cls(type=source_type, config=config_obj, path=path)
def validate(self) -> None:
if not self.config:
raise ConfigError("subgraph config missing", extend_path(self.path, "config"))
if hasattr(self.config, "validate"):
self.config.validate()
FIELD_SPECS = {
"type": ConfigFieldSpec(
name="type",
display_name="Subgraph Source Type",
type_hint="str",
required=True,
description="Registered subgraph source such as 'config' or 'file' (see subgraph_source_registry).",
),
"config": ConfigFieldSpec(
name="config",
display_name="Subgraph Configuration",
type_hint="object",
required=True,
description="Payload interpreted by the chosen type—for example inline graph schema for 'config' or file path payload for 'file'.",
),
}
@classmethod
def child_routes(cls) -> Dict[ChildKey, type[BaseConfig]]:
return {
ChildKey(field="config", value=name): config_cls
for name, config_cls in iter_subgraph_source_registrations().items()
}
@classmethod
def field_specs(cls) -> Dict[str, ConfigFieldSpec]:
specs = super().field_specs()
type_spec = specs.get("type")
if type_spec:
registrations = iter_subgraph_source_registrations()
metadata = iter_subgraph_source_metadata()
names = list(registrations.keys())
descriptions = {
name: (metadata.get(name) or {}).get("summary") for name in names
}
specs["type"] = replace(
type_spec,
enum=names,
enum_options=enum_options_from_values(names, descriptions, preserve_label_case=True),
)
return specs
+94
View File
@@ -0,0 +1,94 @@
"""Thinking configuration models."""
from dataclasses import dataclass, replace
from typing import Any, Dict, Mapping
from entity.enum_options import enum_options_from_values
from schema_registry import (
SchemaLookupError,
get_thinking_schema,
iter_thinking_schemas,
)
from entity.configs.base import BaseConfig, ConfigError, ConfigFieldSpec, ChildKey, extend_path, require_mapping, require_str
@dataclass
class ReflectionThinkingConfig(BaseConfig):
reflection_prompt: str
@classmethod
def from_dict(cls, data: Mapping[str, Any], *, path: str) -> "ReflectionThinkingConfig":
mapping = require_mapping(data, path)
prompt = require_str(mapping, "reflection_prompt", path)
return cls(reflection_prompt=prompt, path=path)
FIELD_SPECS = {
"reflection_prompt": ConfigFieldSpec(
name="reflection_prompt",
display_name="Reflection Prompt",
type_hint="str",
required=True,
description="Prompt used for reflection in reflection mode",
)
}
@dataclass
class ThinkingConfig(BaseConfig):
type: str
config: BaseConfig | None = None
@classmethod
def from_dict(cls, data: Mapping[str, Any], *, path: str) -> "ThinkingConfig":
mapping = require_mapping(data, path)
thinking_type = require_str(mapping, "type", path)
try:
schema = get_thinking_schema(thinking_type)
except SchemaLookupError as exc:
raise ConfigError(f"unsupported thinking type '{thinking_type}'", extend_path(path, "type")) from exc
if "config" not in mapping or mapping["config"] is None:
raise ConfigError("thinking config requires config block", extend_path(path, "config"))
config_obj = schema.config_cls.from_dict(mapping["config"], path=extend_path(path, "config"))
return cls(type=thinking_type, config=config_obj, path=path)
FIELD_SPECS = {
"type": ConfigFieldSpec(
name="type",
display_name="Thinking Mode",
type_hint="str",
required=True,
description="Thinking mode type",
),
"config": ConfigFieldSpec(
name="config",
display_name="Thinking Configuration",
type_hint="object",
required=True,
description="Thinking mode configuration body",
),
}
@classmethod
def child_routes(cls) -> dict[ChildKey, type[BaseConfig]]:
return {
ChildKey(field="config", value=name): schema.config_cls
for name, schema in iter_thinking_schemas().items()
}
@classmethod
def field_specs(cls) -> Dict[str, ConfigFieldSpec]:
specs = super().field_specs()
type_spec = specs.get("type")
if type_spec:
registrations = iter_thinking_schemas()
names = list(registrations.keys())
descriptions = {name: schema.summary for name, schema in registrations.items()}
specs["type"] = replace(
type_spec,
enum=names,
enum_options=enum_options_from_values(names, descriptions, preserve_label_case=True),
)
return specs
+660
View File
@@ -0,0 +1,660 @@
"""Tooling configuration models."""
import hashlib
from copy import deepcopy
from dataclasses import dataclass, field, replace
from typing import Any, Dict, List, Mapping, Tuple
from entity.configs.base import (
BaseConfig,
ConfigError,
ConfigFieldSpec,
EnumOption,
ChildKey,
ensure_list,
optional_bool,
optional_str,
require_mapping,
require_str,
extend_path,
)
from entity.enum_options import enum_options_from_values
from utils.registry import Registry, RegistryError
from utils.function_catalog import FunctionCatalog, get_function_catalog
tooling_type_registry = Registry("tooling_type")
MODULE_ALL_SUFFIX = ":All"
def register_tooling_type(
name: str,
*,
config_cls: type[BaseConfig],
description: str | None = None,
) -> None:
metadata = {"summary": description} if description else None
tooling_type_registry.register(name, target=config_cls, metadata=metadata)
def get_tooling_type_config(name: str) -> type[BaseConfig]:
entry = tooling_type_registry.get(name)
config_cls = entry.load()
if not isinstance(config_cls, type) or not issubclass(config_cls, BaseConfig):
raise RegistryError(f"Entry '{name}' is not a BaseConfig subclass")
return config_cls
def iter_tooling_type_registrations() -> Dict[str, type[BaseConfig]]:
return {name: entry.load() for name, entry in tooling_type_registry.items()}
def iter_tooling_type_metadata() -> Dict[str, Dict[str, Any]]:
return {name: dict(entry.metadata or {}) for name, entry in tooling_type_registry.items()}
@dataclass
class FunctionToolEntryConfig(BaseConfig):
"""Schema helper used to describe per-function options."""
name: str | None = None
description: str | None = None
parameters: Dict[str, Any] | None = None
auto_fill: bool = True
FIELD_SPECS = {
"name": ConfigFieldSpec(
name="name",
display_name="Function Name",
type_hint="str",
required=True,
description="Function name from functions/function_calling directory",
),
# "description": ConfigFieldSpec(
# name="description",
# display_name="Description",
# type_hint="str",
# required=False,
# description="Override auto-parsed function description, optional",
# advance=True,
# ),
# "parameters": ConfigFieldSpec(
# name="parameters",
# display_name="Parameter Schema",
# type_hint="object",
# required=False,
# description="Override JSON Schema generated from function signature, optional",
# advance=True,
# ),
# "auto_fill": ConfigFieldSpec(
# name="auto_fill",
# display_name="Auto Fill Description",
# type_hint="bool",
# required=False,
# default=True,
# description="Whether to auto-fill description/parameters based on Python function signature",
# advance=True,
# ),
}
@classmethod
def field_specs(cls) -> Dict[str, ConfigFieldSpec]:
specs = super().field_specs()
catalog = get_function_catalog()
modules = catalog.iter_modules()
name_spec = specs.get("name")
if name_spec is not None:
description = name_spec.description or "Function name"
enum_options: List[EnumOption] | None = None
enum_values: List[str] | None = None
if catalog.load_error:
description = f"{description} (loading failed: {catalog.load_error})"
elif not modules:
description = f"{description} (no functions found in directory)"
else:
enum_options = []
enum_values = []
for module_name, metas in modules:
all_label = f"{module_name}{MODULE_ALL_SUFFIX}"
enum_values.append(all_label)
preview = ", ".join(meta.name for meta in metas[:3])
suffix = "..." if len(metas) > 3 else ""
module_hint = f"{module_name}.py"
enum_options.append(
EnumOption(
value=all_label,
label=all_label,
description=(
f"Load all {len(metas)} functions from {module_hint}"
+ (f" ({preview}{suffix})" if preview else "")
),
)
)
for module_name, metas in modules:
for meta in metas:
label = f"{module_name}:{meta.name}"
enum_values.append(meta.name)
option_description = meta.description or "This function does not provide a docstring"
enum_options.append(
EnumOption(
value=meta.name,
label=label,
description=option_description,
)
)
specs["name"] = replace(
name_spec,
enum=enum_values,
enum_options=enum_options,
description=description,
)
return specs
@dataclass
class FunctionToolConfig(BaseConfig):
tools: List[Dict[str, Any]]
auto_load: bool = True
timeout: float | None = None
# schema_version: str | None = None
FIELD_SPECS = {
"tools": ConfigFieldSpec(
name="tools",
display_name="Function Tool List",
type_hint="list[FunctionToolEntryConfig]",
required=True,
description="Function tool list, at least one item",
child=FunctionToolEntryConfig,
),
# "auto_load": ConfigFieldSpec(
# name="auto_load",
# display_name="Auto Load Directory",
# type_hint="bool",
# required=False,
# default=True,
# description="Auto-load functions directory on startup",
# advance=True
# ),
"timeout": ConfigFieldSpec(
name="timeout",
display_name="Execution Timeout",
type_hint="float",
required=False,
description="Tool execution timeout (seconds)",
advance=True
),
# "schema_version": ConfigFieldSpec(
# name="schema_version",
# display_name="Schema Version",
# type_hint="str",
# required=False,
# description="Tool schema version",
# ),
}
@classmethod
def from_dict(cls, data: Mapping[str, Any], *, path: str) -> "FunctionToolConfig":
mapping = require_mapping(data, path)
tools = ensure_list(mapping.get("tools"))
if not tools:
raise ConfigError("tools must be provided for function tooling", extend_path(path, "tools"))
catalog = get_function_catalog()
expanded_tools: List[Tuple[Dict[str, Any], str]] = []
for idx, tool in enumerate(tools):
tool_path = extend_path(path, f"tools[{idx}]")
if not isinstance(tool, Mapping):
raise ConfigError("tool entry must be a mapping", tool_path)
normalized = dict(tool)
raw_name = normalized.get("name")
if not isinstance(raw_name, str) or not raw_name.strip():
raise ConfigError("tool name is required", extend_path(tool_path, "name"))
name = raw_name.strip()
normalized["name"] = name
module_name = cls._extract_module_from_all(name)
if module_name:
expanded_tools.extend(
cls._expand_module_all_entry(
module_name=module_name,
catalog=catalog,
path=tool_path,
original=normalized,
)
)
continue
expanded_tools.append((normalized, tool_path))
tool_specs: List[Dict[str, Any]] = []
seen_functions: Dict[str, str] = {}
for entry, entry_path in expanded_tools:
normalized = dict(entry)
name = normalized.get("name")
if not isinstance(name, str) or not name.strip():
raise ConfigError("tool name is required", extend_path(entry_path, "name"))
metadata = catalog.get(name)
if metadata is None:
raise ConfigError(
f"function '{name}' not found under function directory",
extend_path(entry_path, "name"),
)
previous = seen_functions.get(name)
if previous is not None:
raise ConfigError(
f"function '{name}' is declared multiple times (also in {previous})",
extend_path(entry_path, "name"),
)
seen_functions[name] = entry_path
auto_fill = normalized.get("auto_fill", True)
if not isinstance(auto_fill, bool):
raise ConfigError("auto_fill must be boolean", extend_path(entry_path, "auto_fill"))
merged = dict(normalized)
if auto_fill:
if not merged.get("description") and metadata.description:
merged["description"] = metadata.description
if not merged.get("parameters"):
merged["parameters"] = deepcopy(metadata.parameters_schema)
merged.pop("auto_fill", None)
tool_specs.append(merged)
auto_load = optional_bool(mapping, "auto_load", path, default=True)
timeout_value = mapping.get("timeout")
if timeout_value is not None and not isinstance(timeout_value, (int, float)):
raise ConfigError("timeout must be numeric", extend_path(path, "timeout"))
# schema_version = optional_str(mapping, "schema_version", path)
return cls(
tools=tool_specs,
auto_load=bool(auto_load) if auto_load is not None else True,
timeout=float(timeout_value) if isinstance(timeout_value, (int, float)) else None,
# schema_version=schema_version,
path=path,
)
@staticmethod
def _extract_module_from_all(value: str) -> str | None:
if not value.endswith(MODULE_ALL_SUFFIX):
return None
module = value[: -len(MODULE_ALL_SUFFIX)].strip()
return module or None
@staticmethod
def _expand_module_all_entry(
*,
module_name: str,
catalog: FunctionCatalog,
path: str,
original: Mapping[str, Any],
) -> List[Tuple[Dict[str, Any], str]]:
disallowed = [key for key in ("description", "parameters", "auto_fill") if key in original]
if disallowed:
fields = ", ".join(disallowed)
raise ConfigError(
f"{module_name}{MODULE_ALL_SUFFIX} does not support overriding {fields}",
extend_path(path, "name"),
)
functions = catalog.functions_for_module(module_name)
if not functions:
raise ConfigError(
f"module '{module_name}' has no functions under function directory",
extend_path(path, "name"),
)
entries: List[Tuple[Dict[str, Any], str]] = []
for fn_name in functions:
entries.append(({"name": fn_name}, path))
return entries
@dataclass
class McpRemoteConfig(BaseConfig):
server: str
headers: Dict[str, str] = field(default_factory=dict)
timeout: float | None = None
cache_ttl: float = 0.0
tool_sources: List[str] | None = None
FIELD_SPECS = {
"server": ConfigFieldSpec(
name="server",
display_name="MCP Server URL",
type_hint="str",
required=True,
description="HTTP(S) endpoint of the MCP server",
),
"headers": ConfigFieldSpec(
name="headers",
display_name="Custom Headers",
type_hint="dict[str, str]",
required=False,
description="Additional request headers (e.g. Authorization)",
advance=True,
),
"timeout": ConfigFieldSpec(
name="timeout",
display_name="Client Timeout",
type_hint="float",
required=False,
description="Per-request timeout in seconds",
advance=True,
),
"cache_ttl": ConfigFieldSpec(
name="cache_ttl",
display_name="Tool Cache TTL",
type_hint="float",
required=False,
description="Seconds to cache MCP tool list; 0 disables cache for hot updates",
advance=True,
),
"tool_sources": ConfigFieldSpec(
name="tool_sources",
display_name="Tool Sources Filter",
type_hint="list[str]",
required=False,
description="Only include MCP tools whose meta.source is in this list; omit to default to ['mcp_tools'].",
advance=True,
),
}
@classmethod
def from_dict(cls, data: Mapping[str, Any], *, path: str) -> "McpRemoteConfig":
mapping = require_mapping(data, path)
server = require_str(mapping, "server", path)
headers_raw = mapping.get("headers")
headers: Dict[str, str] = {}
if headers_raw is not None:
if not isinstance(headers_raw, Mapping):
raise ConfigError("headers must be a mapping", extend_path(path, "headers"))
headers = {str(k): str(v) for k, v in headers_raw.items()}
timeout_value = mapping.get("timeout")
timeout: float | None
if timeout_value is None:
timeout = None
elif isinstance(timeout_value, (int, float)):
timeout = float(timeout_value)
else:
raise ConfigError("timeout must be numeric", extend_path(path, "timeout"))
cache_ttl_value = mapping.get("cache_ttl", 0.0)
if cache_ttl_value is None:
cache_ttl = 0.0
elif isinstance(cache_ttl_value, (int, float)):
cache_ttl = float(cache_ttl_value)
else:
raise ConfigError("cache_ttl must be numeric", extend_path(path, "cache_ttl"))
tool_sources_raw = mapping.get("tool_sources")
tool_sources: List[str] | None = None
if tool_sources_raw is not None:
entries = ensure_list(tool_sources_raw)
normalized: List[str] = []
for idx, entry in enumerate(entries):
if not isinstance(entry, str):
raise ConfigError(
"tool_sources must be a list of strings",
extend_path(path, f"tool_sources[{idx}]"),
)
value = entry.strip()
if value:
normalized.append(value)
tool_sources = normalized
else:
tool_sources = ["mcp_tools"]
return cls(
server=server,
headers=headers,
timeout=timeout,
cache_ttl=cache_ttl,
tool_sources=tool_sources,
path=path,
)
def cache_key(self) -> str:
payload = (
self.server,
tuple(sorted(self.headers.items())),
self.timeout,
)
return hashlib.sha1(repr(payload).encode("utf-8")).hexdigest()
@dataclass
class McpLocalConfig(BaseConfig):
command: str
args: List[str] = field(default_factory=list)
cwd: str | None = None
env: Dict[str, str] = field(default_factory=dict)
inherit_env: bool = True
startup_timeout: float = 10.0
wait_for_log: str | None = None
cache_ttl: float = 0.0
FIELD_SPECS = {
"command": ConfigFieldSpec(
name="command",
display_name="Launch Command",
type_hint="str",
required=True,
description="Executable used to start the MCP stdio server (e.g. uvx)",
),
"args": ConfigFieldSpec(
name="args",
display_name="Arguments",
type_hint="list[str]",
required=False,
description="Command arguments, defaults to empty list",
),
"cwd": ConfigFieldSpec(
name="cwd",
display_name="Working Directory",
type_hint="str",
required=False,
description="Optional working directory for the launch command",
advance=True,
),
"env": ConfigFieldSpec(
name="env",
display_name="Environment Variables",
type_hint="dict[str, str]",
required=False,
description="Additional environment variables for the process",
advance=True,
),
"inherit_env": ConfigFieldSpec(
name="inherit_env",
display_name="Inherit Parent Env",
type_hint="bool",
required=False,
default=True,
description="Whether to start from parent env before applying overrides",
advance=True,
),
"startup_timeout": ConfigFieldSpec(
name="startup_timeout",
display_name="Startup Timeout",
type_hint="float",
required=False,
default=10.0,
description="Seconds to wait for readiness logs",
advance=True,
),
"wait_for_log": ConfigFieldSpec(
name="wait_for_log",
display_name="Ready Log Pattern",
type_hint="str",
required=False,
description="Regex that marks readiness when matched against stdout",
advance=True,
),
"cache_ttl": ConfigFieldSpec(
name="cache_ttl",
display_name="Tool Cache TTL",
type_hint="float",
required=False,
description="Seconds to cache MCP tool list; 0 disables cache for hot updates",
advance=True,
),
}
@classmethod
def from_dict(cls, data: Mapping[str, Any], *, path: str) -> "McpLocalConfig":
mapping = require_mapping(data, path)
command = require_str(mapping, "command", path)
args_raw = ensure_list(mapping.get("args"))
normalized_args: List[str] = []
for idx, arg in enumerate(args_raw):
arg_path = extend_path(path, f"args[{idx}]")
if not isinstance(arg, str):
raise ConfigError("args entries must be strings", arg_path)
normalized_args.append(arg)
cwd = optional_str(mapping, "cwd", path)
inherit_env = optional_bool(mapping, "inherit_env", path, default=True)
if inherit_env is None:
inherit_env = True
env_mapping = mapping.get("env")
if env_mapping is not None:
if not isinstance(env_mapping, Mapping):
raise ConfigError("env must be a mapping", extend_path(path, "env"))
env = {str(k): str(v) for k, v in env_mapping.items()}
else:
env = {}
timeout_value = mapping.get("startup_timeout", 10.0)
if timeout_value is None:
startup_timeout = 10.0
elif isinstance(timeout_value, (int, float)):
startup_timeout = float(timeout_value)
else:
raise ConfigError("startup_timeout must be numeric", extend_path(path, "startup_timeout"))
wait_for_log = optional_str(mapping, "wait_for_log", path)
cache_ttl_value = mapping.get("cache_ttl", 0.0)
if cache_ttl_value is None:
cache_ttl = 0.0
elif isinstance(cache_ttl_value, (int, float)):
cache_ttl = float(cache_ttl_value)
else:
raise ConfigError("cache_ttl must be numeric", extend_path(path, "cache_ttl"))
return cls(
command=command,
args=normalized_args,
cwd=cwd,
env=env,
inherit_env=bool(inherit_env),
startup_timeout=startup_timeout,
wait_for_log=wait_for_log,
cache_ttl=cache_ttl,
path=path,
)
def cache_key(self) -> str:
payload = (
self.command,
tuple(self.args),
self.cwd or "",
tuple(sorted(self.env.items())),
self.inherit_env,
self.startup_timeout,
self.wait_for_log or "",
)
return hashlib.sha1(repr(payload).encode("utf-8")).hexdigest()
register_tooling_type(
"function",
config_cls=FunctionToolConfig,
description="Use local Python functions",
)
register_tooling_type(
"mcp_remote",
config_cls=McpRemoteConfig,
description="Connect to an HTTP-based MCP server",
)
register_tooling_type(
"mcp_local",
config_cls=McpLocalConfig,
description="Launch and connect to a local stdio MCP server",
)
@dataclass
class ToolingConfig(BaseConfig):
type: str
config: BaseConfig | None = None
prefix: str | None = None
FIELD_SPECS = {
"type": ConfigFieldSpec(
name="type",
display_name="Tool Type",
type_hint="str",
required=True,
description="Select a tooling adapter registered via tooling_type_registry (function, mcp_remote, mcp_local, etc.).",
),
"prefix": ConfigFieldSpec(
name="prefix",
display_name="Tool Prefix",
type_hint="str",
required=False,
description="Optional prefix for all tools from this source to prevent name collisions (e.g. 'mcp1').",
advance=True,
),
"config": ConfigFieldSpec(
name="config",
display_name="Tool Configuration",
type_hint="object",
required=True,
description="Configuration block validated by the chosen tool type (Python function list, MCP server settings, local command MCP launch, etc.).",
),
}
@classmethod
def child_routes(cls) -> Dict[ChildKey, type[BaseConfig]]:
return {
ChildKey(field="config", value=name): config_cls
for name, config_cls in iter_tooling_type_registrations().items()
}
@classmethod
def from_dict(cls, data: Mapping[str, Any], *, path: str) -> "ToolingConfig":
mapping = require_mapping(data, path)
tooling_type = require_str(mapping, "type", path)
try:
config_cls = get_tooling_type_config(tooling_type)
except RegistryError as exc:
raise ConfigError(
f"tooling.type must be one of {list(iter_tooling_type_registrations().keys())}",
extend_path(path, "type"),
) from exc
config_payload = mapping.get("config")
if config_payload is None:
raise ConfigError("tooling requires config block", extend_path(path, "config"))
config_obj = config_cls.from_dict(config_payload, path=extend_path(path, "config"))
prefix = optional_str(mapping, "prefix", path)
return cls(type=tooling_type, config=config_obj, prefix=prefix, path=path)
@classmethod
def field_specs(cls) -> Dict[str, ConfigFieldSpec]:
specs = super().field_specs()
type_spec = specs.get("type")
if type_spec:
registrations = iter_tooling_type_registrations()
metadata = iter_tooling_type_metadata()
type_names = list(registrations.keys())
default_value = type_names[0] if type_names else None
descriptions = {name: (metadata.get(name) or {}).get("summary") for name in type_names}
specs["type"] = replace(
type_spec,
enum=type_names,
default=default_value,
enum_options=enum_options_from_values(type_names, descriptions),
)
return specs
+74
View File
@@ -0,0 +1,74 @@
"""Helper utilities for building EnumOption metadata."""
from enum import Enum
from typing import Dict, List, Mapping, Sequence, Type, TypeVar
from entity.configs.base import EnumOption
from entity.enums import LogLevel, AgentExecFlowStage, AgentInputMode
from utils.strs import titleize
EnumT = TypeVar("EnumT", bound=Enum)
_ENUM_DESCRIPTIONS: Dict[Type[Enum], Dict[Enum, str]] = {
LogLevel: {
LogLevel.DEBUG: "Verbose developer logging; useful when debugging graph behavior.",
LogLevel.INFO: "High-level execution progress and key checkpoints.",
LogLevel.WARNING: "Recoverable problems that require attention but do not stop the run.",
LogLevel.ERROR: "Errors that abort the current node or edge execution, even the entire workflow.",
LogLevel.CRITICAL: "Fatal issues that stop the workflow immediately.",
},
AgentInputMode: {
AgentInputMode.PROMPT: "Send a single string prompt assembled from previous messages.",
AgentInputMode.MESSAGES: "Send structured role/content messages (Chat Completions style) which is recommended.",
},
AgentExecFlowStage: {
AgentExecFlowStage.PRE_GEN_THINKING_STAGE: "Pre-generation thinking / planning stage.",
AgentExecFlowStage.GEN_STAGE: "Main generation stage; also covers tool calling.",
AgentExecFlowStage.POST_GEN_THINKING_STAGE: "Reflection or verification after generation.",
AgentExecFlowStage.FINISHED_STAGE: "Finalization stage for cleanup and summary.",
},
}
def enum_options_for(enum_cls: Type[EnumT]) -> List[EnumOption]:
"""Return EnumOption entries for a Python Enum class."""
descriptions = _ENUM_DESCRIPTIONS.get(enum_cls, {})
options: List[EnumOption] = []
for member in enum_cls:
label = titleize(member.name)
options.append(EnumOption(value=member.value, label=label, description=descriptions.get(member)))
return options
def enum_options_from_values(
values: Sequence[str],
descriptions: Mapping[str, str | None] | None = None,
*,
preserve_label_case: bool = False,
) -> List[EnumOption]:
"""Create EnumOption entries from literal string values."""
options: List[EnumOption] = []
desc_map = descriptions or {}
for value in values:
label = value if preserve_label_case else titleize(value)
options.append(EnumOption(value=value, label=label, description=desc_map.get(value)))
return options
def describe_enums_map() -> Dict[str, Dict[str, str]]:
"""Return a serializable description map (mostly for tests/debugging)."""
payload: Dict[str, Dict[str, str]] = {}
for enum_cls, mapping in _ENUM_DESCRIPTIONS.items():
payload[enum_cls.__name__] = {member.value: text for member, text in mapping.items() if text}
return payload
__all__ = [
"enum_options_for",
"enum_options_from_values",
"describe_enums_map",
]
+86
View File
@@ -0,0 +1,86 @@
from enum import Enum
class AgentExecFlowStage(str, Enum):
"""Execution stages used to orchestrate agent workflows."""
# INPUT_STAGE = "input"
PRE_GEN_THINKING_STAGE = "pre_gen_thinking"
GEN_STAGE = "gen" # Includes tool calling plus the final response when applicable
POST_GEN_THINKING_STAGE = "post_gen_thinking"
FINISHED_STAGE = "finished"
class LogLevel(str, Enum):
DEBUG = "DEBUG"
INFO = "INFO"
WARNING = "WARNING"
ERROR = "ERROR"
CRITICAL = "CRITICAL"
__level_values = {
"DEBUG": 10,
"INFO": 20,
"WARNING": 30,
"ERROR": 40,
"CRITICAL": 50,
}
@property
def level(self) -> int:
return self.__level_values[self.value]
def __lt__(self, other):
if isinstance(other, LogLevel):
return self.level < other.level
return NotImplemented
def __le__(self, other):
if isinstance(other, LogLevel):
return self.level <= other.level
return NotImplemented
def __gt__(self, other):
if isinstance(other, LogLevel):
return self.level > other.level
return NotImplemented
def __ge__(self, other):
if isinstance(other, LogLevel):
return self.level >= other.level
return NotImplemented
def __eq__(self, other):
if isinstance(other, LogLevel):
return self.level == other.level
return super().__eq__(other)
def __hash__(self):
return super().__hash__()
class EventType(str, Enum):
NODE_START = "NODE_START"
NODE_END = "NODE_END"
EDGE_PROCESS = "EDGE_PROCESS"
MODEL_CALL = "MODEL_CALL"
TOOL_CALL = "TOOL_CALL"
AGENT_CALL = "AGENT_CALL"
HUMAN_INTERACTION = "HUMAN_INTERACTION"
THINKING_PROCESS = "THINKING_PROCESS"
MEMORY_OPERATION = "MEMORY_OPERATION"
WORKFLOW_START = "WORKFLOW_START"
WORKFLOW_END = "WORKFLOW_END"
TEST = "TEST"
class CallStage(str, Enum):
BEFORE = "before"
AFTER = "after"
class AgentInputMode(str, Enum):
"""Controls how node inputs are fed into agent providers."""
PROMPT = "prompt"
MESSAGES = "messages"
+94
View File
@@ -0,0 +1,94 @@
"""GraphConfig wraps parsed graph definitions with runtime metadata."""
from dataclasses import dataclass, field
from pathlib import Path
from typing import Any, Dict, List, Optional
from entity.enums import LogLevel
from entity.configs import GraphDefinition, MemoryStoreConfig, Node, EdgeConfig
@dataclass
class GraphConfig:
definition: GraphDefinition
name: str
output_root: Path
log_level: LogLevel
metadata: Dict[str, Any] = field(default_factory=dict)
source_path: Optional[str] = None
vars: Dict[str, Any] = field(default_factory=dict)
@classmethod
def from_dict(
cls,
config: Dict[str, Any],
name: str,
output_root: Path | str,
*,
source_path: str | None = None,
vars: Dict[str, Any] | None = None,
) -> "GraphConfig":
definition = GraphDefinition.from_dict(config, path="graph")
return cls(
definition=definition,
name=name,
output_root=Path(output_root) if output_root else Path("WareHouse"),
log_level=definition.log_level,
metadata={},
source_path=source_path,
vars=dict(vars or {}),
)
@classmethod
def from_definition(
cls,
definition: GraphDefinition,
name: str,
output_root: Path | str,
*,
source_path: str | None = None,
vars: Dict[str, Any] | None = None,
) -> "GraphConfig":
return cls(
definition=definition,
name=name,
output_root=Path(output_root) if output_root else Path("WareHouse"),
log_level=definition.log_level,
metadata={},
source_path=source_path,
vars=dict(vars or {}),
)
def get_node_definitions(self) -> List[Node]:
return self.definition.nodes
def get_edge_definitions(self) -> List[EdgeConfig]:
return self.definition.edges
def get_memory_config(self) -> List[MemoryStoreConfig] | None:
return self.definition.memory
def get_organization(self) -> str:
return self.definition.organization or "DefaultOrg"
def get_source_path(self) -> str:
if self.source_path:
return self.source_path
return self.definition.id or "config.yaml"
def get_initial_instruction(self) -> str:
return self.definition.initial_instruction or ""
@property
def is_majority_voting(self) -> bool:
return self.definition.is_majority_voting
def to_dict(self) -> Dict[str, Any]:
return {
"name": self.name,
"output_root": str(self.output_root),
"log_level": self.log_level.value,
"metadata": self.metadata,
"graph": self.definition,
"vars": self.vars,
}
+425
View File
@@ -0,0 +1,425 @@
"""Core message abstractions used across providers and executors."""
import copy
from dataclasses import dataclass, field
import json
from enum import Enum
from typing import Any, Dict, List, Optional, Union
class MessageRole(str, Enum):
"""Unified message roles for internal conversations."""
SYSTEM = "system"
USER = "user"
ASSISTANT = "assistant"
TOOL = "tool"
class MessageBlockType(str, Enum):
"""Supported block types for multimodal message content."""
TEXT = "text"
IMAGE = "image"
AUDIO = "audio"
VIDEO = "video"
FILE = "file"
DATA = "data"
@classmethod
def from_mime_type(cls, mime_type: str) -> "MessageBlockType":
"""Guess block type from MIME type."""
if not mime_type:
return MessageBlockType.FILE
if mime_type.startswith("image/"):
return MessageBlockType.IMAGE
if mime_type.startswith("audio/"):
return MessageBlockType.AUDIO
if mime_type.startswith("video/"):
return MessageBlockType.VIDEO
return MessageBlockType.FILE
@dataclass
class AttachmentRef:
"""Metadata for a payload stored locally or uploaded to a provider."""
attachment_id: str
mime_type: Optional[str] = None
name: Optional[str] = None
size: Optional[int] = None
sha256: Optional[str] = None
local_path: Optional[str] = None
remote_file_id: Optional[str] = None
data_uri: Optional[str] = None
metadata: Dict[str, Any] = field(default_factory=dict)
def to_dict(self, include_data: bool = True) -> Dict[str, Any]:
payload: Dict[str, Any] = {
"attachment_id": self.attachment_id,
"mime_type": self.mime_type,
"name": self.name,
"size": self.size,
"sha256": self.sha256,
"local_path": self.local_path,
"remote_file_id": self.remote_file_id,
"metadata": dict(self.metadata),
}
if include_data and self.data_uri:
payload["data_uri"] = self.data_uri
elif self.data_uri and not include_data:
payload["data_uri"] = "[omitted]"
# Remove keys that are None to keep payload compact
return {key: value for key, value in payload.items() if value is not None and value != {}}
@classmethod
def from_dict(cls, data: Dict[str, Any]) -> "AttachmentRef":
return cls(
attachment_id=data.get("attachment_id", ""),
mime_type=data.get("mime_type"),
name=data.get("name"),
size=data.get("size"),
sha256=data.get("sha256"),
local_path=data.get("local_path"),
remote_file_id=data.get("remote_file_id"),
data_uri=data.get("data_uri"),
metadata=data.get("metadata") or {},
)
def copy(self) -> "AttachmentRef":
return AttachmentRef(
attachment_id=self.attachment_id,
mime_type=self.mime_type,
name=self.name,
size=self.size,
sha256=self.sha256,
local_path=self.local_path,
remote_file_id=self.remote_file_id,
data_uri=self.data_uri,
metadata=dict(self.metadata),
)
@dataclass
class MessageBlock:
"""Single block of multimodal content."""
type: MessageBlockType
text: Optional[str] = None
attachment: Optional[AttachmentRef] = None
data: Dict[str, Any] = field(default_factory=dict)
def to_dict(self, include_data: bool = True) -> Dict[str, Any]:
payload: Dict[str, Any] = {
"type": self.type.value,
}
if self.text is not None:
payload["text"] = self.text
if self.attachment:
payload["attachment"] = self.attachment.to_dict(include_data=include_data)
if self.data:
payload["data"] = self.data
return payload
@classmethod
def from_dict(cls, data: Dict[str, Any]) -> "MessageBlock":
raw_type = data.get("type") or MessageBlockType.TEXT.value
try:
block_type = MessageBlockType(raw_type)
except ValueError:
block_type = MessageBlockType.DATA
attachment_data = data.get("attachment")
attachment = None
if isinstance(attachment_data, dict):
attachment = AttachmentRef.from_dict(attachment_data)
return cls(
type=block_type,
text=data.get("text"),
attachment=attachment,
data=data.get("data") or {},
)
@classmethod
def text_block(cls, text: str) -> "MessageBlock":
return cls(type=MessageBlockType.TEXT, text=text)
def describe(self) -> str:
"""Human-friendly summary for logging."""
if self.type is MessageBlockType.TEXT and self.text:
return self.text
if self.attachment:
name = self.attachment.name or self.attachment.attachment_id
return f"[{self.type.value} attachment: {name}]"
if self.text:
return self.text
if "text" in self.data:
return str(self.data["text"])
return f"[{self.type.value} block]"
def copy(self) -> "MessageBlock":
return MessageBlock(
type=self.type,
text=self.text,
attachment=self.attachment.copy() if self.attachment else None,
data=dict(self.data),
)
@dataclass
class ToolCallPayload:
"""Unified representation of a tool call request."""
id: str
function_name: str
arguments: str
type: str = "function"
metadata: Dict[str, Any] = field(default_factory=dict)
def to_openai_dict(self) -> Dict[str, Any]:
"""Convert to OpenAI-compatible schema."""
return {
"id": self.id,
"type": self.type,
"function": {
"name": self.function_name,
"arguments": self.arguments,
},
}
@dataclass
class FunctionCallOutputEvent:
"""Structured event recorded when a tool execution finishes."""
call_id: str
function_name: Optional[str] = None
output_blocks: List[MessageBlock] = field(default_factory=list)
output_text: Optional[str] = None
metadata: Dict[str, Any] = field(default_factory=dict)
@property
def type(self) -> str:
return "function_call_output"
def to_dict(self, include_data: bool = True) -> Dict[str, Any]:
payload: Dict[str, Any] = {
"type": self.type,
"call_id": self.call_id,
}
if self.function_name:
payload["function_name"] = self.function_name
if self.output_blocks:
payload["output_blocks"] = [
block.to_dict(include_data=include_data) for block in self.output_blocks
]
if self.output_text is not None:
payload["output"] = self.output_text
if self.metadata:
payload["metadata"] = self.metadata
return payload
def has_blocks(self) -> bool:
return bool(self.output_blocks)
def describe(self) -> str:
if self.output_text:
return self.output_text
if self.output_blocks:
descriptions = [block.describe() for block in self.output_blocks]
return "\n".join(filter(None, descriptions))
return ""
MessageContent = Union[str, List[MessageBlock], List[Dict[str, Any]]]
@dataclass
class Message:
"""Unified message structure shared by executors and providers."""
role: MessageRole
content: MessageContent
name: Optional[str] = None
tool_call_id: Optional[str] = None
metadata: Dict[str, Any] = field(default_factory=dict)
tool_calls: List[ToolCallPayload] = field(default_factory=list)
keep: bool = False
preserve_role: bool = False
def with_content(self, content: MessageContent) -> "Message":
"""Return a shallow copy with updated content."""
return Message(
role=self.role,
content=content,
name=self.name,
tool_call_id=self.tool_call_id,
metadata=dict(self.metadata),
tool_calls=list(self.tool_calls),
keep=self.keep,
preserve_role=self.preserve_role,
)
def with_role(self, role: MessageRole) -> "Message":
"""Return a shallow copy with updated role."""
return Message(
role=role,
content=self.content,
name=self.name,
tool_call_id=self.tool_call_id,
metadata=dict(self.metadata),
tool_calls=list(self.tool_calls),
keep=self.keep,
preserve_role=self.preserve_role,
)
def text_content(self) -> str:
"""Best-effort string representation of the content."""
if self.content is None:
return ""
if isinstance(self.content, str):
return self.content
# Some providers (e.g., multimodal) return list content; join textual parts.
parts = []
for block in self.blocks():
description = block.describe()
if description:
parts.append(description)
return "\n".join(parts)
def blocks(self) -> List[MessageBlock]:
"""Return content as a list of MessageBlock items."""
if self.content is None:
return []
if isinstance(self.content, str):
return [MessageBlock.text_block(self.content)]
blocks: List[MessageBlock] = []
for block in self.content:
if isinstance(block, MessageBlock):
blocks.append(block)
elif isinstance(block, dict):
try:
blocks.append(MessageBlock.from_dict(block))
except Exception:
# Fallback to text representation of unexpected dicts
text_value = block.get("text") if isinstance(block, dict) else None
blocks.append(MessageBlock(MessageBlockType.DATA, text=text_value, data=block if isinstance(block, dict) else {}))
return blocks
def clone(self) -> "Message":
"""Deep copy of the message, preserving content blocks."""
return Message(
role=self.role,
content=_copy_content(self.content),
name=self.name,
tool_call_id=self.tool_call_id,
metadata=dict(self.metadata),
tool_calls=list(self.tool_calls),
keep=self.keep,
preserve_role=self.preserve_role,
)
def to_dict(self, include_data: bool = True) -> Dict[str, Any]:
"""Return a JSON-serializable representation."""
payload = {
"role": self.role.value,
}
if isinstance(self.content, list):
payload["content"] = [
block.to_dict(include_data=include_data) if isinstance(block, MessageBlock) else block for block in self.content
]
else:
payload["content"] = self.content
if self.name:
payload["name"] = self.name
if self.tool_call_id:
payload["tool_call_id"] = self.tool_call_id
if self.metadata:
payload["metadata"] = self.metadata
if self.tool_calls:
payload["tool_calls"] = [call.to_openai_dict() for call in self.tool_calls]
if self.keep:
payload["keep"] = self.keep
if self.preserve_role:
payload["preserve_role"] = self.preserve_role
return payload
@classmethod
def from_dict(cls, data: Dict[str, Any]) -> "Message":
role_value = data.get("role")
if not role_value:
raise ValueError("message dict missing role")
role = MessageRole(role_value)
content = data.get("content")
if isinstance(content, list):
converted: List[MessageBlock] = []
for block in content:
if isinstance(block, MessageBlock):
converted.append(block)
elif isinstance(block, dict):
try:
converted.append(MessageBlock.from_dict(block))
except Exception:
# Preserve raw dict for debugging; text_content will stringify best-effort
converted.append(
MessageBlock(
type=MessageBlockType.DATA,
text=str(block),
data=block,
)
)
content = converted
tool_calls_data = data.get("tool_calls") or []
tool_calls: List[ToolCallPayload] = []
for item in tool_calls_data:
if not isinstance(item, dict):
continue
fn = item.get("function", {}) or {}
metadata = item.get("metadata") or {}
tool_calls.append(
ToolCallPayload(
id=item.get("id", ""),
function_name=fn.get("name", ""),
arguments=fn.get("arguments", ""),
type=item.get("type", "function"),
metadata=metadata,
)
)
return cls(
role=role,
content=content,
name=data.get("name"),
tool_call_id=data.get("tool_call_id"),
metadata=data.get("metadata") or {},
tool_calls=tool_calls,
keep=bool(data.get("keep", False)),
preserve_role=bool(data.get("preserve_role", False)),
)
def serialize_messages(messages: List[Message], *, include_data: bool = True) -> str:
"""Serialize message list into JSON string."""
return json.dumps([msg.to_dict(include_data=include_data) for msg in messages], ensure_ascii=False)
def deserialize_messages(payload: str) -> List[Message]:
"""Deserialize JSON string back to messages."""
if not payload:
return []
raw = json.loads(payload)
if not isinstance(raw, list):
raise ValueError("message payload must be a list")
return [Message.from_dict(item) for item in raw if isinstance(item, dict)]
def _copy_content(content: MessageContent) -> MessageContent:
if content is None:
return None
if isinstance(content, str):
return content
copied: List[Any] = []
for block in content:
if isinstance(block, MessageBlock):
copied.append(block.copy())
else:
copied.append(copy.deepcopy(block))
return copied
+31
View File
@@ -0,0 +1,31 @@
"""Provider-agnostic tool specification dataclasses."""
from dataclasses import dataclass, field
from typing import Any, Dict
@dataclass
class ToolSpec:
"""Generic representation of a callable tool."""
name: str
description: str = ""
parameters: Dict[str, Any] = field(default_factory=dict)
metadata: Dict[str, Any] = field(default_factory=dict)
def to_openai_dict(self) -> Dict[str, Any]:
"""Convert to OpenAI Responses API function schema."""
return {
"type": "function",
"name": self.name,
"description": self.description,
"parameters": self.parameters or {"type": "object", "properties": {}},
}
def to_gemini_function(self) -> Dict[str, Any]:
"""Convert to Gemini FunctionDeclaration schema."""
return {
"name": self.name,
"description": self.description,
"parameters": self.parameters or {"type": "object", "properties": {}},
}