chore: import upstream snapshot with attribution
Validate YAML Workflows / Validate YAML Configuration Files (push) Has been cancelled
Validate YAML Workflows / Validate YAML Configuration Files (push) Has been cancelled
This commit is contained in:
Executable
Executable
+33
@@ -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))
|
||||
Executable
+58
@@ -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",
|
||||
]
|
||||
Executable
+276
@@ -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}"
|
||||
Executable
+443
@@ -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)
|
||||
Executable
+17
@@ -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",
|
||||
]
|
||||
Executable
+183
@@ -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",
|
||||
)
|
||||
Executable
+151
@@ -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 configuration(type + 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,
|
||||
),
|
||||
}
|
||||
Executable
+302
@@ -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
|
||||
Executable
+334
@@ -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
|
||||
Executable
+313
@@ -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)
|
||||
Executable
+22
@@ -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",
|
||||
]
|
||||
Executable
+577
@@ -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
|
||||
Executable
+29
@@ -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",
|
||||
)
|
||||
}
|
||||
Executable
+69
@@ -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.",
|
||||
),
|
||||
}
|
||||
Executable
+79
@@ -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,
|
||||
),
|
||||
}
|
||||
@@ -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,
|
||||
),
|
||||
}
|
||||
Executable
+533
@@ -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,
|
||||
),
|
||||
}
|
||||
Executable
+471
@@ -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
|
||||
Executable
+32
@@ -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.",
|
||||
),
|
||||
}
|
||||
Executable
+97
@@ -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,
|
||||
),
|
||||
}
|
||||
@@ -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
|
||||
Executable
+266
@@ -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
|
||||
Executable
+94
@@ -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
|
||||
Executable
+660
@@ -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
|
||||
Executable
+74
@@ -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",
|
||||
]
|
||||
Executable
+86
@@ -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"
|
||||
Executable
+94
@@ -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,
|
||||
}
|
||||
Executable
+425
@@ -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
|
||||
Executable
+31
@@ -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": {}},
|
||||
}
|
||||
Reference in New Issue
Block a user