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
+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
|
||||
Reference in New Issue
Block a user