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

This commit is contained in:
wehub-resource-sync
2026-07-13 12:37:51 +08:00
commit d0e4308def
614 changed files with 74458 additions and 0 deletions
+22
View File
@@ -0,0 +1,22 @@
"""Node config conveniences."""
from .agent import AgentConfig, AgentRetryConfig
from .human import HumanConfig
from .subgraph import SubgraphConfig
from .passthrough import PassthroughConfig
from .python_runner import PythonRunnerConfig
from .skills import AgentSkillsConfig
from .node import Node
from .literal import LiteralNodeConfig
__all__ = [
"AgentConfig",
"AgentRetryConfig",
"AgentSkillsConfig",
"HumanConfig",
"SubgraphConfig",
"PassthroughConfig",
"PythonRunnerConfig",
"LiteralNodeConfig",
"Node",
]
+577
View File
@@ -0,0 +1,577 @@
"""Agent-specific configuration dataclasses."""
from dataclasses import dataclass, field, replace
from typing import Any, Dict, Iterable, List, Mapping, Sequence
try: # pragma: no cover - Python < 3.11 lacks BaseExceptionGroup
from builtins import BaseExceptionGroup as _BASE_EXCEPTION_GROUP_TYPE # type: ignore[attr-defined]
except ImportError: # pragma: no cover
_BASE_EXCEPTION_GROUP_TYPE = None # type: ignore[assignment]
from entity.enums import AgentInputMode
from schema_registry import iter_model_provider_schemas
from utils.strs import titleize
from entity.configs.base import (
BaseConfig,
ConfigError,
ConfigFieldSpec,
EnumOption,
optional_bool,
optional_dict,
optional_str,
require_mapping,
require_str,
extend_path,
)
from .memory import MemoryAttachmentConfig
from .skills import AgentSkillsConfig
from .thinking import ThinkingConfig
from entity.configs.node.tooling import ToolingConfig
DEFAULT_RETRYABLE_STATUS_CODES = [408, 409, 425, 429, 500, 502, 503, 504]
DEFAULT_RETRYABLE_EXCEPTION_TYPES = [
"RateLimitError",
"APITimeoutError",
"APIError",
"APIConnectionError",
"ServiceUnavailableError",
"TimeoutError",
"InternalServerError",
"RemoteProtocolError",
"TransportError",
"ConnectError",
"ConnectTimeout",
"ReadError",
"ReadTimeout",
]
DEFAULT_RETRYABLE_MESSAGE_SUBSTRINGS = [
"rate limit",
"temporarily unavailable",
"timeout",
"server disconnected",
"connection reset",
]
def _coerce_float(value: Any, *, field_path: str, minimum: float = 0.0) -> float:
if isinstance(value, (int, float)):
coerced = float(value)
else:
raise ConfigError("expected number", field_path)
if coerced < minimum:
raise ConfigError(f"value must be >= {minimum}", field_path)
return coerced
def _coerce_positive_int(value: Any, *, field_path: str, minimum: int = 1) -> int:
if isinstance(value, bool):
raise ConfigError("expected integer", field_path)
if isinstance(value, int):
coerced = value
else:
raise ConfigError("expected integer", field_path)
if coerced < minimum:
raise ConfigError(f"value must be >= {minimum}", field_path)
return coerced
def _coerce_str_list(value: Any, *, field_path: str) -> List[str]:
if value is None:
return []
if not isinstance(value, Sequence) or isinstance(value, (str, bytes)):
raise ConfigError("expected list of strings", field_path)
result: List[str] = []
for idx, item in enumerate(value):
if not isinstance(item, str):
raise ConfigError("expected list of strings", f"{field_path}[{idx}]")
result.append(item.strip())
return result
def _coerce_int_list(value: Any, *, field_path: str) -> List[int]:
if value is None:
return []
if not isinstance(value, Sequence) or isinstance(value, (str, bytes)):
raise ConfigError("expected list of integers", field_path)
ints: List[int] = []
for idx, item in enumerate(value):
if isinstance(item, bool) or not isinstance(item, int):
raise ConfigError("expected list of integers", f"{field_path}[{idx}]")
ints.append(item)
return ints
@dataclass
class AgentRetryConfig(BaseConfig):
enabled: bool = True
max_attempts: int = 5
min_wait_seconds: float = 1.0
max_wait_seconds: float = 6.0
retry_on_status_codes: List[int] = field(default_factory=lambda: list(DEFAULT_RETRYABLE_STATUS_CODES))
retry_on_exception_types: List[str] = field(default_factory=lambda: [name.lower() for name in DEFAULT_RETRYABLE_EXCEPTION_TYPES])
non_retry_exception_types: List[str] = field(default_factory=list)
retry_on_error_substrings: List[str] = field(default_factory=lambda: list(DEFAULT_RETRYABLE_MESSAGE_SUBSTRINGS))
FIELD_SPECS = {
"enabled": ConfigFieldSpec(
name="enabled",
display_name="Enable Retry",
type_hint="bool",
required=False,
default=True,
description="Toggle automatic retry for provider calls",
),
"max_attempts": ConfigFieldSpec(
name="max_attempts",
display_name="Max Attempts",
type_hint="int",
required=False,
default=5,
description="Maximum number of total attempts (initial call + retries)",
),
"min_wait_seconds": ConfigFieldSpec(
name="min_wait_seconds",
display_name="Min Wait Seconds",
type_hint="float",
required=False,
default=1.0,
description="Minimum backoff wait before retry",
advance=True,
),
"max_wait_seconds": ConfigFieldSpec(
name="max_wait_seconds",
display_name="Max Wait Seconds",
type_hint="float",
required=False,
default=6.0,
description="Maximum backoff wait before retry",
advance=True,
),
"retry_on_status_codes": ConfigFieldSpec(
name="retry_on_status_codes",
display_name="Retryable Status Codes",
type_hint="list[int]",
required=False,
description="HTTP status codes that should trigger a retry",
advance=True,
),
"retry_on_exception_types": ConfigFieldSpec(
name="retry_on_exception_types",
display_name="Retryable Exception Types",
type_hint="list[str]",
required=False,
description="Exception class names (case-insensitive) that should trigger retries",
advance=True,
),
"non_retry_exception_types": ConfigFieldSpec(
name="non_retry_exception_types",
display_name="Non-Retryable Exception Types",
type_hint="list[str]",
required=False,
description="Exception class names (case-insensitive) that should never retry",
advance=True,
),
"retry_on_error_substrings": ConfigFieldSpec(
name="retry_on_error_substrings",
display_name="Retryable Message Substrings",
type_hint="list[str]",
required=False,
description="Substring matches within exception messages that enable retry",
advance=True,
),
}
@classmethod
def from_dict(cls, data: Mapping[str, Any], *, path: str) -> "AgentRetryConfig":
mapping = require_mapping(data, path)
enabled = optional_bool(mapping, "enabled", path, default=True)
if enabled is None:
enabled = True
max_attempts = _coerce_positive_int(mapping.get("max_attempts", 5), field_path=extend_path(path, "max_attempts"))
min_wait = _coerce_float(mapping.get("min_wait_seconds", 1.0), field_path=extend_path(path, "min_wait_seconds"), minimum=0.0)
max_wait = _coerce_float(mapping.get("max_wait_seconds", 6.0), field_path=extend_path(path, "max_wait_seconds"), minimum=0.0)
if max_wait < min_wait:
raise ConfigError("max_wait_seconds must be >= min_wait_seconds", extend_path(path, "max_wait_seconds"))
status_codes = mapping.get("retry_on_status_codes")
if status_codes is None:
retry_status_codes = list(DEFAULT_RETRYABLE_STATUS_CODES)
else:
retry_status_codes = _coerce_int_list(status_codes, field_path=extend_path(path, "retry_on_status_codes"))
retry_types_raw = mapping.get("retry_on_exception_types")
if retry_types_raw is None:
retry_types = [name.lower() for name in DEFAULT_RETRYABLE_EXCEPTION_TYPES]
else:
retry_types = [value.lower() for value in _coerce_str_list(retry_types_raw, field_path=extend_path(path, "retry_on_exception_types")) if value]
non_retry_types = [value.lower() for value in _coerce_str_list(mapping.get("non_retry_exception_types"), field_path=extend_path(path, "non_retry_exception_types")) if value]
retry_substrings_raw = mapping.get("retry_on_error_substrings")
if retry_substrings_raw is None:
retry_substrings = list(DEFAULT_RETRYABLE_MESSAGE_SUBSTRINGS)
else:
retry_substrings = [
value.lower()
for value in _coerce_str_list(
retry_substrings_raw,
field_path=extend_path(path, "retry_on_error_substrings"),
)
if value
]
return cls(
enabled=enabled,
max_attempts=max_attempts,
min_wait_seconds=min_wait,
max_wait_seconds=max_wait,
retry_on_status_codes=retry_status_codes,
retry_on_exception_types=retry_types,
non_retry_exception_types=non_retry_types,
retry_on_error_substrings=retry_substrings,
path=path,
)
@property
def is_active(self) -> bool:
return self.enabled and self.max_attempts > 1
def should_retry(self, exc: BaseException) -> bool:
if not self.is_active:
return False
chain: List[tuple[BaseException, set[str], int | None, str]] = []
for error in self._iter_exception_chain(exc):
chain.append(
(
error,
self._exception_name_set(error),
self._extract_status_code(error),
str(error).lower(),
)
)
if self.non_retry_exception_types:
for _, names, _, _ in chain:
if any(name in names for name in self.non_retry_exception_types):
return False
if self.retry_on_exception_types:
for _, names, _, _ in chain:
if any(name in names for name in self.retry_on_exception_types):
return True
if self.retry_on_status_codes:
for _, _, status_code, _ in chain:
if status_code is not None and status_code in self.retry_on_status_codes:
return True
if self.retry_on_error_substrings:
for _, _, _, message in chain:
if message and any(substr in message for substr in self.retry_on_error_substrings):
return True
return False
def _exception_name_set(self, exc: BaseException) -> set[str]:
names: set[str] = set()
for cls in exc.__class__.mro():
names.add(cls.__name__.lower())
names.add(f"{cls.__module__}.{cls.__name__}".lower())
return names
def _extract_status_code(self, exc: BaseException) -> int | None:
for attr in ("status_code", "http_status", "status", "statusCode"):
value = getattr(exc, attr, None)
if isinstance(value, int):
return value
response = getattr(exc, "response", None)
if response is not None:
for attr in ("status_code", "status", "statusCode"):
value = getattr(response, attr, None)
if isinstance(value, int):
return value
return None
def _iter_exception_chain(self, exc: BaseException) -> Iterable[BaseException]:
seen: set[int] = set()
stack: List[BaseException] = [exc]
while stack:
current = stack.pop()
if id(current) in seen:
continue
seen.add(id(current))
yield current
linked: List[BaseException] = []
cause = getattr(current, "__cause__", None)
context = getattr(current, "__context__", None)
if isinstance(cause, BaseException):
linked.append(cause)
if isinstance(context, BaseException):
linked.append(context)
if _BASE_EXCEPTION_GROUP_TYPE is not None and isinstance(current, _BASE_EXCEPTION_GROUP_TYPE):
for exc_item in getattr(current, "exceptions", None) or ():
if isinstance(exc_item, BaseException):
linked.append(exc_item)
stack.extend(linked)
@dataclass
class AgentConfig(BaseConfig):
provider: str
base_url: str
name: str
role: str | None = None
api_key: str | None = None
params: Dict[str, Any] = field(default_factory=dict)
retry: AgentRetryConfig | None = None
input_mode: AgentInputMode = AgentInputMode.MESSAGES
tooling: List[ToolingConfig] = field(default_factory=list)
thinking: ThinkingConfig | None = None
memories: List[MemoryAttachmentConfig] = field(default_factory=list)
skills: AgentSkillsConfig | None = None
# Runtime attributes (attached dynamically)
token_tracker: Any | None = field(default=None, init=False, repr=False)
node_id: str | None = field(default=None, init=False, repr=False)
@classmethod
def from_dict(cls, data: Mapping[str, Any], *, path: str) -> "AgentConfig":
mapping = require_mapping(data, path)
provider = require_str(mapping, "provider", path)
base_url = optional_str(mapping, "base_url", path)
name_value = mapping.get("name")
if isinstance(name_value, str) and name_value.strip():
model_name = name_value.strip()
else:
raise ConfigError("model.name must be a non-empty string", extend_path(path, "name"))
role = optional_str(mapping, "role", path)
api_key = optional_str(mapping, "api_key", path)
params = optional_dict(mapping, "params", path) or {}
raw_input_mode = optional_str(mapping, "input_mode", path)
input_mode = AgentInputMode.MESSAGES
if raw_input_mode:
try:
input_mode = AgentInputMode(raw_input_mode.strip().lower())
except ValueError as exc:
raise ConfigError(
"model.input_mode must be 'prompt' or 'messages'",
extend_path(path, "input_mode"),
) from exc
tooling_cfg: List[ToolingConfig] = []
if "tooling" in mapping and mapping["tooling"] is not None:
raw_tooling = mapping["tooling"]
if not isinstance(raw_tooling, list):
raise ConfigError("tooling must be a list", extend_path(path, "tooling"))
for idx, item in enumerate(raw_tooling):
tooling_cfg.append(
ToolingConfig.from_dict(item, path=extend_path(path, f"tooling[{idx}]"))
)
thinking_cfg = None
if "thinking" in mapping and mapping["thinking"] is not None:
thinking_cfg = ThinkingConfig.from_dict(mapping["thinking"], path=extend_path(path, "thinking"))
memories_cfg: List[MemoryAttachmentConfig] = []
if "memories" in mapping and mapping["memories"] is not None:
raw_memories = mapping["memories"]
if not isinstance(raw_memories, list):
raise ConfigError("memories must be a list", extend_path(path, "memories"))
for idx, item in enumerate(raw_memories):
memories_cfg.append(
MemoryAttachmentConfig.from_dict(item, path=extend_path(path, f"memories[{idx}]"))
)
retry_cfg = None
if "retry" in mapping and mapping["retry"] is not None:
retry_cfg = AgentRetryConfig.from_dict(mapping["retry"], path=extend_path(path, "retry"))
skills_cfg = None
if "skills" in mapping and mapping["skills"] is not None:
skills_cfg = AgentSkillsConfig.from_dict(mapping["skills"], path=extend_path(path, "skills"))
return cls(
provider=provider,
base_url=base_url,
name=model_name,
role=role,
api_key=api_key,
params=params,
tooling=tooling_cfg,
thinking=thinking_cfg,
memories=memories_cfg,
skills=skills_cfg,
retry=retry_cfg,
input_mode=input_mode,
path=path,
)
FIELD_SPECS = {
"name": ConfigFieldSpec(
name="name",
display_name="Model Name",
type_hint="str",
required=True,
description="Specific model name e.g. gpt-4o",
),
"role": ConfigFieldSpec(
name="role",
display_name="System Prompt",
type_hint="text",
required=False,
description="Model system prompt",
),
"provider": ConfigFieldSpec(
name="provider",
display_name="Model Provider",
type_hint="str",
required=True,
description="Name of a registered provider (openai, gemini, etc.) that selects the underlying client adapter.",
default="openai",
),
"base_url": ConfigFieldSpec(
name="base_url",
display_name="Base URL",
type_hint="str",
required=False,
description="Override the provider's default endpoint; leave empty to use the built-in base URL.",
advance=True,
default="${BASE_URL}",
),
"api_key": ConfigFieldSpec(
name="api_key",
display_name="API Key",
type_hint="str",
required=False,
description="Credential consumed by the provider client; reference an env var such as ${API_KEY} that matches the selected provider.",
advance=True,
default="${API_KEY}",
),
"params": ConfigFieldSpec(
name="params",
display_name="Call Parameters",
type_hint="dict[str, Any]",
required=False,
default={},
description="Call parameters (temperature, top_p, etc.)",
advance=True,
),
# "input_mode": ConfigFieldSpec( # currently, many features depend on messages mode, so hide this and force messages
# name="input_mode",
# display_name="Input Mode",
# type_hint="enum:AgentInputMode",
# required=False,
# default=AgentInputMode.MESSAGES.value,
# description="Model input mode: messages (default) or prompt",
# enum=[item.value for item in AgentInputMode],
# advance=True,
# enum_options=enum_options_for(AgentInputMode),
# ),
"tooling": ConfigFieldSpec(
name="tooling",
display_name="Tool Configuration",
type_hint="list[ToolingConfig]",
required=False,
description="Bound tool configuration list",
child=ToolingConfig,
advance=True,
),
"thinking": ConfigFieldSpec(
name="thinking",
display_name="Thinking Configuration",
type_hint="ThinkingConfig",
required=False,
description="Thinking process configuration",
child=ThinkingConfig,
advance=True,
),
"memories": ConfigFieldSpec(
name="memories",
display_name="Memory Attachments",
type_hint="list[MemoryAttachmentConfig]",
required=False,
description="Associated memory references",
child=MemoryAttachmentConfig,
advance=True,
),
"skills": ConfigFieldSpec(
name="skills",
display_name="Agent Skills",
type_hint="AgentSkillsConfig",
required=False,
description="Agent Skills allowlist and built-in skill activation/file-read tools.",
child=AgentSkillsConfig,
advance=True,
),
"retry": ConfigFieldSpec(
name="retry",
display_name="Retry Policy",
type_hint="AgentRetryConfig",
required=False,
description="Automatic retry policy for this model",
child=AgentRetryConfig,
advance=True,
),
}
@classmethod
def field_specs(cls) -> Dict[str, ConfigFieldSpec]:
specs = super().field_specs()
provider_spec = specs.get("provider")
if provider_spec:
enum_spec = cls._apply_provider_enum(provider_spec)
specs["provider"] = enum_spec
return specs
@staticmethod
def _apply_provider_enum(provider_spec: ConfigFieldSpec) -> ConfigFieldSpec:
provider_names, metadata = AgentConfig._provider_registry_snapshot()
if not provider_names:
return provider_spec
enum_options: List[EnumOption] = []
for name in provider_names:
meta = metadata.get(name) or {}
label = meta.get("label") or titleize(name)
enum_options.append(
EnumOption(
value=name,
label=label,
description=meta.get("summary"),
)
)
default_value = provider_spec.default
if not default_value or default_value not in provider_names:
default_value = AgentConfig._preferred_provider_default(provider_names)
return replace(
provider_spec,
enum=provider_names,
enum_options=enum_options,
default=default_value,
)
@staticmethod
def _preferred_provider_default(provider_names: List[str]) -> str:
if "openai" in provider_names:
return "openai"
return provider_names[0]
@staticmethod
def _provider_registry_snapshot() -> tuple[List[str], Dict[str, Dict[str, Any]]]:
specs = iter_model_provider_schemas()
names = list(specs.keys())
metadata: Dict[str, Dict[str, Any]] = {}
for name, spec in specs.items():
metadata[name] = {
"label": spec.label,
"summary": spec.summary,
**(spec.metadata or {}),
}
return names, metadata
+29
View File
@@ -0,0 +1,29 @@
"""Human node configuration."""
from dataclasses import dataclass
from typing import Any, Mapping
from entity.configs.base import BaseConfig, ConfigFieldSpec, optional_str, require_mapping
@dataclass
class HumanConfig(BaseConfig):
description: str | None = None
@classmethod
def from_dict(cls, data: Mapping[str, Any] | None, *, path: str) -> "HumanConfig":
if data is None:
return cls(description=None, path=path)
mapping = require_mapping(data, path)
description = optional_str(mapping, "description", path)
return cls(description=description, path=path)
FIELD_SPECS = {
"description": ConfigFieldSpec(
name="description",
display_name="Human Task Description",
type_hint="text",
required=False,
description="Description of the task for human to complete",
)
}
+69
View File
@@ -0,0 +1,69 @@
"""Configuration for literal nodes."""
from dataclasses import dataclass
from typing import Mapping, Any
from entity.configs.base import (
BaseConfig,
ConfigError,
ConfigFieldSpec,
EnumOption,
optional_str,
require_mapping,
require_str,
)
from entity.messages import MessageRole
@dataclass
class LiteralNodeConfig(BaseConfig):
"""Config describing the literal payload emitted by the node."""
content: str = ""
role: MessageRole = MessageRole.USER
@classmethod
def from_dict(cls, data: Mapping[str, Any], *, path: str) -> "LiteralNodeConfig":
mapping = require_mapping(data, path)
content = require_str(mapping, "content", path)
if not content:
raise ConfigError("content cannot be empty", f"{path}.content")
role_value = optional_str(mapping, "role", path)
role = MessageRole.USER
if role_value:
normalized = role_value.strip().lower()
if normalized not in (MessageRole.USER.value, MessageRole.ASSISTANT.value):
raise ConfigError("role must be 'user' or 'assistant'", f"{path}.role")
role = MessageRole(normalized)
return cls(content=content, role=role, path=path)
def validate(self) -> None:
if not self.content:
raise ConfigError("content cannot be empty", f"{self.path}.content")
if self.role not in (MessageRole.USER, MessageRole.ASSISTANT):
raise ConfigError("role must be 'user' or 'assistant'", f"{self.path}.role")
FIELD_SPECS = {
"content": ConfigFieldSpec(
name="content",
display_name="Literal Content",
type_hint="text",
required=True,
description="Plain text emitted whenever the node executes.",
),
"role": ConfigFieldSpec(
name="role",
display_name="Message Role",
type_hint="str",
required=False,
default=MessageRole.USER.value,
enum=[MessageRole.USER.value, MessageRole.ASSISTANT.value],
enum_options=[
EnumOption(value=MessageRole.USER.value, label="user"),
EnumOption(value=MessageRole.ASSISTANT.value, label="assistant"),
],
description="Select whether the literal message should appear as a user or assistant entry.",
),
}
+79
View File
@@ -0,0 +1,79 @@
"""Configuration for loop counter guard nodes."""
from dataclasses import dataclass
from typing import Mapping, Any, Optional
from entity.configs.base import (
BaseConfig,
ConfigError,
ConfigFieldSpec,
require_mapping,
extend_path,
optional_str,
)
@dataclass
class LoopCounterConfig(BaseConfig):
"""Configuration schema for the loop counter node type."""
max_iterations: int = 10
reset_on_emit: bool = True
message: Optional[str] = None
@classmethod
def from_dict(cls, data: Mapping[str, Any] | None, *, path: str) -> "LoopCounterConfig":
mapping = require_mapping(data or {}, path)
max_iterations_raw = mapping.get("max_iterations", 10)
try:
max_iterations = int(max_iterations_raw)
except (TypeError, ValueError) as exc: # pragma: no cover - defensive
raise ConfigError(
"max_iterations must be an integer",
extend_path(path, "max_iterations"),
) from exc
if max_iterations < 1:
raise ConfigError("max_iterations must be >= 1", extend_path(path, "max_iterations"))
reset_on_emit = bool(mapping.get("reset_on_emit", True))
message = optional_str(mapping, "message", path)
return cls(
max_iterations=max_iterations,
reset_on_emit=reset_on_emit,
message=message,
path=path,
)
def validate(self) -> None:
if self.max_iterations < 1:
raise ConfigError("max_iterations must be >= 1", extend_path(self.path, "max_iterations"))
FIELD_SPECS = {
"max_iterations": ConfigFieldSpec(
name="max_iterations",
display_name="Maximum Iterations",
type_hint="int",
required=True,
default=10,
description="How many times the loop can run before this node emits an output.",
),
"reset_on_emit": ConfigFieldSpec(
name="reset_on_emit",
display_name="Reset After Emit",
type_hint="bool",
required=False,
default=True,
description="Whether to reset the internal counter after reaching the limit.",
advance=True,
),
"message": ConfigFieldSpec(
name="message",
display_name="Release Message",
type_hint="text",
required=False,
description="Optional text sent downstream once the iteration cap is reached.",
advance=True,
),
}
+133
View File
@@ -0,0 +1,133 @@
"""Configuration for loop timer guard nodes."""
from dataclasses import dataclass
from typing import Mapping, Any, Optional
from entity.configs.base import (
BaseConfig,
ConfigError,
ConfigFieldSpec,
EnumOption,
require_mapping,
extend_path,
optional_str,
)
@dataclass
class LoopTimerConfig(BaseConfig):
"""Configuration schema for the loop timer node type."""
max_duration: float = 60.0
duration_unit: str = "seconds"
reset_on_emit: bool = True
message: Optional[str] = None
passthrough: bool = False
@classmethod
def from_dict(
cls, data: Mapping[str, Any] | None, *, path: str
) -> "LoopTimerConfig":
mapping = require_mapping(data or {}, path)
max_duration_raw = mapping.get("max_duration", 60.0)
try:
max_duration = float(max_duration_raw)
except (TypeError, ValueError) as exc: # pragma: no cover - defensive
raise ConfigError(
"max_duration must be a number",
extend_path(path, "max_duration"),
) from exc
if max_duration <= 0:
raise ConfigError(
"max_duration must be > 0", extend_path(path, "max_duration")
)
duration_unit = str(mapping.get("duration_unit", "seconds"))
valid_units = ["seconds", "minutes", "hours"]
if duration_unit not in valid_units:
raise ConfigError(
f"duration_unit must be one of: {', '.join(valid_units)}",
extend_path(path, "duration_unit"),
)
reset_on_emit = bool(mapping.get("reset_on_emit", True))
message = optional_str(mapping, "message", path)
passthrough = bool(mapping.get("passthrough", False))
return cls(
max_duration=max_duration,
duration_unit=duration_unit,
reset_on_emit=reset_on_emit,
message=message,
passthrough=passthrough,
path=path,
)
def validate(self) -> None:
if self.max_duration <= 0:
raise ConfigError(
"max_duration must be > 0", extend_path(self.path, "max_duration")
)
valid_units = ["seconds", "minutes", "hours"]
if self.duration_unit not in valid_units:
raise ConfigError(
f"duration_unit must be one of: {', '.join(valid_units)}",
extend_path(self.path, "duration_unit"),
)
FIELD_SPECS = {
"max_duration": ConfigFieldSpec(
name="max_duration",
display_name="Maximum Duration",
type_hint="float",
required=True,
default=60.0,
description="How long the loop can run before this node emits an output.",
),
"duration_unit": ConfigFieldSpec(
name="duration_unit",
display_name="Duration Unit",
type_hint="str",
required=True,
default="seconds",
description="Unit of time for max_duration: 'seconds', 'minutes', or 'hours'.",
enum=["seconds", "minutes", "hours"],
enum_options=[
EnumOption(
value="seconds", label="Seconds", description="Time in seconds"
),
EnumOption(
value="minutes", label="Minutes", description="Time in minutes"
),
EnumOption(value="hours", label="Hours", description="Time in hours"),
],
),
"reset_on_emit": ConfigFieldSpec(
name="reset_on_emit",
display_name="Reset After Emit",
type_hint="bool",
required=False,
default=True,
description="Whether to reset the internal timer after reaching the limit.",
advance=True,
),
"message": ConfigFieldSpec(
name="message",
display_name="Release Message",
type_hint="text",
required=False,
description="Optional text sent downstream once the time limit is reached.",
advance=True,
),
"passthrough": ConfigFieldSpec(
name="passthrough",
display_name="Passthrough Mode",
type_hint="bool",
required=False,
default=False,
description="If true, after emitting the limit message, all subsequent inputs pass through unchanged.",
advance=True,
),
}
+533
View File
@@ -0,0 +1,533 @@
"""Memory-related configuration dataclasses."""
from dataclasses import dataclass, field, replace
from typing import Any, Dict, List, Mapping
from entity.enums import AgentExecFlowStage
from entity.enum_options import enum_options_for, enum_options_from_values
from schema_registry import (
SchemaLookupError,
get_memory_store_schema,
iter_memory_store_schemas,
)
from entity.configs.base import (
BaseConfig,
ConfigError,
ConfigFieldSpec,
ChildKey,
ensure_list,
optional_dict,
optional_str,
require_mapping,
require_str,
extend_path,
)
@dataclass
class EmbeddingConfig(BaseConfig):
provider: str
model: str
api_key: str | None = None
base_url: str | None = None
params: Dict[str, Any] = field(default_factory=dict)
@classmethod
def from_dict(cls, data: Mapping[str, Any], *, path: str) -> "EmbeddingConfig":
mapping = require_mapping(data, path)
provider = require_str(mapping, "provider", path)
model = require_str(mapping, "model", path)
api_key = optional_str(mapping, "api_key", path)
base_url = optional_str(mapping, "base_url", path)
params = optional_dict(mapping, "params", path) or {}
return cls(provider=provider, model=model, api_key=api_key, base_url=base_url, params=params, path=path)
FIELD_SPECS = {
"provider": ConfigFieldSpec(
name="provider",
display_name="Embedding Provider",
type_hint="str",
required=True,
default="openai",
description="Embedding provider",
),
"model": ConfigFieldSpec(
name="model",
display_name="Embedding Model",
type_hint="str",
required=True,
default="text-embedding-3-small",
description="Embedding model name",
),
"api_key": ConfigFieldSpec(
name="api_key",
display_name="API Key",
type_hint="str",
required=False,
description="API key",
default="${API_KEY}",
advance=True,
),
"base_url": ConfigFieldSpec(
name="base_url",
display_name="Base URL",
type_hint="str",
required=False,
description="Custom Base URL",
default="${BASE_URL}",
advance=True,
),
"params": ConfigFieldSpec(
name="params",
display_name="Custom Parameters",
type_hint="dict[str, Any]",
required=False,
default={},
description="Embedding parameters (temperature, etc.)",
advance=True,
),
}
@dataclass
class FileSourceConfig(BaseConfig):
source_path: str
file_types: List[str] | None = None
recursive: bool = True
encoding: str = "utf-8"
@classmethod
def from_dict(cls, data: Mapping[str, Any], *, path: str) -> "FileSourceConfig":
mapping = require_mapping(data, path)
file_path = require_str(mapping, "path", path)
file_types_value = mapping.get("file_types")
file_types: List[str] | None = None
if file_types_value is not None:
items = ensure_list(file_types_value)
normalized: List[str] = []
for idx, item in enumerate(items):
if not isinstance(item, str):
raise ConfigError("file_types entries must be strings", extend_path(path, f"file_types[{idx}]") )
normalized.append(item)
file_types = normalized
recursive_value = mapping.get("recursive", True)
if not isinstance(recursive_value, bool):
raise ConfigError("recursive must be boolean", extend_path(path, "recursive"))
encoding = optional_str(mapping, "encoding", path) or "utf-8"
return cls(source_path=file_path, file_types=file_types, recursive=recursive_value, encoding=encoding, path=path)
FIELD_SPECS = {
"path": ConfigFieldSpec(
name="path",
display_name="File/Directory Path",
type_hint="str",
required=True,
description="Path to file/directory to be indexed",
),
"file_types": ConfigFieldSpec(
name="file_types",
display_name="File Type Filter",
type_hint="list[str]",
required=False,
description="List of file type suffixes to limit (e.g. .md, .txt)",
),
"recursive": ConfigFieldSpec(
name="recursive",
display_name="Recursive Subdirectories",
type_hint="bool",
required=False,
default=True,
description="Whether to include subdirectories recursively",
advance=True,
),
"encoding": ConfigFieldSpec(
name="encoding",
display_name="File Encoding",
type_hint="str",
required=False,
default="utf-8",
description="Encoding used to read files",
advance=True,
),
}
@dataclass
class SimpleMemoryConfig(BaseConfig):
memory_path: str | None = None
embedding: EmbeddingConfig | None = None
@classmethod
def from_dict(cls, data: Mapping[str, Any], *, path: str) -> "SimpleMemoryConfig":
mapping = require_mapping(data, path)
memory_path = optional_str(mapping, "memory_path", path)
embedding_cfg = None
if "embedding" in mapping and mapping["embedding"] is not None:
embedding_cfg = EmbeddingConfig.from_dict(mapping["embedding"], path=extend_path(path, "embedding"))
return cls(memory_path=memory_path, embedding=embedding_cfg, path=path)
FIELD_SPECS = {
"memory_path": ConfigFieldSpec(
name="memory_path",
display_name="Memory File Path",
type_hint="str",
required=False,
description="Simple memory file path",
advance=True,
),
"embedding": ConfigFieldSpec(
name="embedding",
display_name="Embedding Configuration",
type_hint="EmbeddingConfig",
required=False,
description="Optional embedding configuration",
child=EmbeddingConfig,
),
}
@dataclass
class FileMemoryConfig(BaseConfig):
index_path: str | None = None
file_sources: List[FileSourceConfig] = field(default_factory=list)
embedding: EmbeddingConfig | None = None
@classmethod
def from_dict(cls, data: Mapping[str, Any], *, path: str) -> "FileMemoryConfig":
mapping = require_mapping(data, path)
sources_raw = ensure_list(mapping.get("file_sources"))
if not sources_raw:
raise ConfigError("file_sources must contain at least one entry", extend_path(path, "file_sources"))
sources: List[FileSourceConfig] = []
for idx, item in enumerate(sources_raw):
sources.append(FileSourceConfig.from_dict(item, path=extend_path(path, f"file_sources[{idx}]")))
index_path = optional_str(mapping, "index_path", path)
if index_path is None:
index_path = optional_str(mapping, "memory_path", path)
embedding_cfg = None
if "embedding" in mapping and mapping["embedding"] is not None:
embedding_cfg = EmbeddingConfig.from_dict(mapping["embedding"], path=extend_path(path, "embedding"))
return cls(index_path=index_path, file_sources=sources, embedding=embedding_cfg, path=path)
FIELD_SPECS = {
"index_path": ConfigFieldSpec(
name="index_path",
display_name="Index Path",
type_hint="str",
required=False,
description="Vector index storage path",
advance=True,
),
"file_sources": ConfigFieldSpec(
name="file_sources",
display_name="File Source List",
type_hint="list[FileSourceConfig]",
required=True,
description="List of file sources to ingest",
child=FileSourceConfig,
),
"embedding": ConfigFieldSpec(
name="embedding",
display_name="Embedding Configuration",
type_hint="EmbeddingConfig",
required=False,
description="Embedding used for file memory",
child=EmbeddingConfig,
),
}
@dataclass
class BlackboardMemoryConfig(BaseConfig):
memory_path: str | None = None
max_items: int = 1000
@classmethod
def from_dict(cls, data: Mapping[str, Any], *, path: str) -> "BlackboardMemoryConfig":
mapping = require_mapping(data, path)
memory_path = optional_str(mapping, "memory_path", path)
max_items_value = mapping.get("max_items", 1000)
if not isinstance(max_items_value, int) or max_items_value <= 0:
raise ConfigError("max_items must be a positive integer", extend_path(path, "max_items"))
return cls(memory_path=memory_path, max_items=max_items_value, path=path)
FIELD_SPECS = {
"memory_path": ConfigFieldSpec(
name="memory_path",
display_name="Blackboard Path",
type_hint="str",
required=False,
description="JSON path for blackboard memory writing. Pass 'auto' to auto-create in working directory, valid for this run only",
default="auto",
advance=True,
),
"max_items": ConfigFieldSpec(
name="max_items",
display_name="Maximum Items",
type_hint="int",
required=False,
default=1000,
description="Maximum number of memory items to retain (trimmed by time)",
advance=True,
),
}
@dataclass
class Mem0MemoryConfig(BaseConfig):
"""Configuration for Mem0 managed memory service."""
api_key: str = ""
org_id: str | None = None
project_id: str | None = None
user_id: str | None = None
agent_id: str | None = None
@classmethod
def from_dict(cls, data: Mapping[str, Any], *, path: str) -> "Mem0MemoryConfig":
mapping = require_mapping(data, path)
api_key = require_str(mapping, "api_key", path)
org_id = optional_str(mapping, "org_id", path)
project_id = optional_str(mapping, "project_id", path)
user_id = optional_str(mapping, "user_id", path)
agent_id = optional_str(mapping, "agent_id", path)
return cls(
api_key=api_key,
org_id=org_id,
project_id=project_id,
user_id=user_id,
agent_id=agent_id,
path=path,
)
FIELD_SPECS = {
"api_key": ConfigFieldSpec(
name="api_key",
display_name="Mem0 API Key",
type_hint="str",
required=True,
description="Mem0 API key (get one from app.mem0.ai)",
default="${MEM0_API_KEY}",
),
"org_id": ConfigFieldSpec(
name="org_id",
display_name="Organization ID",
type_hint="str",
required=False,
description="Mem0 organization ID for scoping",
advance=True,
),
"project_id": ConfigFieldSpec(
name="project_id",
display_name="Project ID",
type_hint="str",
required=False,
description="Mem0 project ID for scoping",
advance=True,
),
"user_id": ConfigFieldSpec(
name="user_id",
display_name="User ID",
type_hint="str",
required=False,
description="User ID for user-scoped memories. Mutually exclusive with agent_id in API calls.",
),
"agent_id": ConfigFieldSpec(
name="agent_id",
display_name="Agent ID",
type_hint="str",
required=False,
description="Agent ID for agent-scoped memories. Mutually exclusive with user_id in API calls.",
),
}
@dataclass
class MemoryStoreConfig(BaseConfig):
name: str
type: str
config: BaseConfig | None = None
@classmethod
def from_dict(cls, data: Mapping[str, Any], *, path: str) -> "MemoryStoreConfig":
mapping = require_mapping(data, path)
name = require_str(mapping, "name", path)
store_type = require_str(mapping, "type", path)
try:
schema = get_memory_store_schema(store_type)
except SchemaLookupError as exc:
raise ConfigError(f"unsupported memory store type '{store_type}'", extend_path(path, "type")) from exc
if "config" not in mapping or mapping["config"] is None:
raise ConfigError("memory store requires config block", extend_path(path, "config"))
config_obj = schema.config_cls.from_dict(mapping["config"], path=extend_path(path, "config"))
return cls(name=name, type=store_type, config=config_obj, path=path)
def require_payload(self) -> BaseConfig:
if not self.config:
raise ConfigError("memory store payload missing", extend_path(self.path, "config"))
return self.config
FIELD_SPECS = {
"name": ConfigFieldSpec(
name="name",
display_name="Store Name",
type_hint="str",
required=True,
description="Unique name of the memory store",
),
"type": ConfigFieldSpec(
name="type",
display_name="Store Type",
type_hint="str",
required=True,
description="Memory store type",
),
"config": ConfigFieldSpec(
name="config",
display_name="Store Configuration",
type_hint="object",
required=True,
description="Schema required by the selected store type (simple/file/blackboard/etc.), following that type's required keys.",
),
}
@classmethod
def child_routes(cls) -> Dict[ChildKey, type[BaseConfig]]:
return {
ChildKey(field="config", value=name): schema.config_cls
for name, schema in iter_memory_store_schemas().items()
}
@classmethod
def field_specs(cls) -> Dict[str, ConfigFieldSpec]:
specs = super().field_specs()
type_spec = specs.get("type")
if type_spec:
registrations = iter_memory_store_schemas()
names = list(registrations.keys())
descriptions = {name: schema.summary for name, schema in registrations.items()}
specs["type"] = replace(
type_spec,
enum=names,
enum_options=enum_options_from_values(names, descriptions, preserve_label_case=True),
)
return specs
@dataclass
class MemoryAttachmentConfig(BaseConfig):
name: str
retrieve_stage: List[AgentExecFlowStage] | None = None
top_k: int = 3
similarity_threshold: float = -1.0
read: bool = True
write: bool = True
@classmethod
def from_dict(cls, data: Mapping[str, Any], *, path: str) -> "MemoryAttachmentConfig":
mapping = require_mapping(data, path)
name = require_str(mapping, "name", path)
stages_raw = mapping.get("retrieve_stage")
stages: List[AgentExecFlowStage] | None = None
if stages_raw is not None:
stage_list = ensure_list(stages_raw)
parsed: List[AgentExecFlowStage] = []
for idx, item in enumerate(stage_list):
try:
parsed.append(AgentExecFlowStage(item))
except ValueError as exc:
raise ConfigError(
f"retrieve_stage entries must be one of {[stage.value for stage in AgentExecFlowStage]}",
extend_path(path, f"retrieve_stage[{idx}]"),
) from exc
stages = parsed
top_k_value = mapping.get("top_k", 3)
if not isinstance(top_k_value, int) or top_k_value <= 0:
raise ConfigError("top_k must be a positive integer", extend_path(path, "top_k"))
threshold_value = mapping.get("similarity_threshold", -1.0)
if not isinstance(threshold_value, (int, float)):
raise ConfigError("similarity_threshold must be numeric", extend_path(path, "similarity_threshold"))
read_value = mapping.get("read", True)
if not isinstance(read_value, bool):
raise ConfigError("read must be boolean", extend_path(path, "read"))
write_value = mapping.get("write", True)
if not isinstance(write_value, bool):
raise ConfigError("write must be boolean", extend_path(path, "write"))
return cls(
name=name,
retrieve_stage=stages,
top_k=top_k_value,
similarity_threshold=float(threshold_value),
read=read_value,
write=write_value,
path=path,
)
FIELD_SPECS = {
"name": ConfigFieldSpec(
name="name",
display_name="Memory Name",
type_hint="str",
required=True,
description="Name of the referenced memory store",
),
"retrieve_stage": ConfigFieldSpec(
name="retrieve_stage",
display_name="Retrieve Stage",
type_hint="list[AgentExecFlowStage]",
required=False,
description="Execution stages when memory retrieval occurs. If not set, defaults to all stages. NOTE: this config is related to thinking, if the thinking module is not used, this config has only effect on `gen` stage.",
enum=[stage.value for stage in AgentExecFlowStage],
enum_options=enum_options_for(AgentExecFlowStage),
),
"top_k": ConfigFieldSpec(
name="top_k",
display_name="Top K",
type_hint="int",
required=False,
default=3,
description="Number of items to retrieve",
advance=True,
),
"similarity_threshold": ConfigFieldSpec(
name="similarity_threshold",
display_name="Similarity Threshold",
type_hint="float",
required=False,
default=-1.0,
description="Similarity threshold (-1 means no similarity threshold filter)",
advance=True,
),
"read": ConfigFieldSpec(
name="read",
display_name="Allow Read",
type_hint="bool",
required=False,
default=True,
description="Whether to read this memory during execution",
advance=True,
),
"write": ConfigFieldSpec(
name="write",
display_name="Allow Write",
type_hint="bool",
required=False,
default=True,
description="Whether to write back to this memory after execution",
advance=True,
),
}
+471
View File
@@ -0,0 +1,471 @@
"""Node configuration dataclasses."""
from dataclasses import dataclass, field, replace
from typing import Any, Dict, Iterable, List, Mapping, Optional, Tuple
from entity.messages import Message, MessageRole
from schema_registry import (
SchemaLookupError,
get_node_schema,
iter_node_schemas,
)
from entity.configs.base import (
BaseConfig,
ConfigError,
ConfigFieldSpec,
EnumOption,
ChildKey,
ensure_list,
optional_str,
require_mapping,
require_str,
extend_path,
)
from entity.configs.edge.edge_condition import EdgeConditionConfig
from entity.configs.edge.edge_processor import EdgeProcessorConfig
from entity.configs.edge.dynamic_edge_config import DynamicEdgeConfig
from entity.configs.node.agent import AgentConfig
from entity.configs.node.human import HumanConfig
from entity.configs.node.tooling import FunctionToolConfig
NodePayload = Message
@dataclass
class EdgeLink:
target: "Node"
config: Dict[str, Any] = field(default_factory=dict)
trigger: bool = True
condition: str = "true"
condition_config: EdgeConditionConfig | None = None
condition_type: str | None = None
condition_metadata: Dict[str, Any] = field(default_factory=dict)
triggered: bool = False
carry_data: bool = True
keep_message: bool = False
clear_context: bool = False
clear_kept_context: bool = False
condition_manager: Any = None
process_config: EdgeProcessorConfig | None = None
process_type: str | None = None
process_metadata: Dict[str, Any] = field(default_factory=dict)
payload_processor: Any = None
dynamic_config: DynamicEdgeConfig | None = None
def __post_init__(self) -> None:
self.config = dict(self.config or {})
@dataclass
class Node(BaseConfig):
id: str
type: str
description: str | None = None
# keep_context: bool = False
log_output: bool = True
context_window: int = 0
vars: Dict[str, Any] = field(default_factory=dict)
config: BaseConfig | None = None
# dynamic configuration has been moved to edges (DynamicEdgeConfig)
input: List[Message] = field(default_factory=list)
output: List[NodePayload] = field(default_factory=list)
# Runtime flag for explicit graph start nodes
start_triggered: bool = False
predecessors: List["Node"] = field(default_factory=list, repr=False)
successors: List["Node"] = field(default_factory=list, repr=False)
_outgoing_edges: List[EdgeLink] = field(default_factory=list, repr=False)
FIELD_SPECS = {
"id": ConfigFieldSpec(
name="id",
display_name="Node ID",
type_hint="str",
required=True,
description="Unique node identifier",
),
"type": ConfigFieldSpec(
name="type",
display_name="Node Type",
type_hint="str",
required=True,
description="Select a node type registered in node.registry (agent, human, python_runner, etc.); it determines the config schema.",
),
"description": ConfigFieldSpec(
name="description",
display_name="Node Description",
type_hint="str",
required=False,
advance=True,
description="Short summary shown in consoles/logs to explain this node's role or prompt context.",
),
# "keep_context": ConfigFieldSpec(
# name="keep_context",
# display_name="Preserve Context",
# type_hint="bool",
# required=False,
# default=False,
# description="Nodes clear their context by default; set to True to keep context data after execution.",
# ),
"context_window": ConfigFieldSpec(
name="context_window",
display_name="Context Window Size",
type_hint="int",
required=False,
default=0,
description="Number of context messages accessible during node execution. 0 means clear all context except messages with keep_message=True, -1 means unlimited, other values represent the number of context messages to keep besides those with keep_message=True.",
# advance=True,
),
"log_output": ConfigFieldSpec(
name="log_output",
display_name="Log Output",
type_hint="bool",
required=False,
default=True,
advance=True,
description="Whether to log this node's output content. Set to false to avoid logging outputs.",
),
"config": ConfigFieldSpec(
name="config",
display_name="Node Configuration",
type_hint="object",
required=True,
description="Configuration object required by the chosen node type (see Schema API for the supported fields).",
),
# Dynamic execution configuration has been moved to edges (DynamicEdgeConfig)
}
@classmethod
def child_routes(cls) -> Dict[ChildKey, type[BaseConfig]]:
routes: Dict[ChildKey, type[BaseConfig]] = {}
for name, schema in iter_node_schemas().items():
routes[ChildKey(field="config", value=name)] = schema.config_cls
return routes
@classmethod
def field_specs(cls) -> Dict[str, ConfigFieldSpec]:
specs = super().field_specs()
type_spec = specs.get("type")
if type_spec:
registrations = iter_node_schemas()
specs["type"] = replace(
type_spec,
enum=list(registrations.keys()),
enum_options=[
EnumOption(
value=name,
label=name,
description=schema.summary or "No description provided for this node type",
)
for name, schema in registrations.items()
],
)
return specs
@classmethod
def from_dict(cls, data: Mapping[str, Any], *, path: str) -> "Node":
mapping = require_mapping(data, path)
node_id = require_str(mapping, "id", path)
node_type = require_str(mapping, "type", path)
try:
schema = get_node_schema(node_type)
except SchemaLookupError as exc:
raise ConfigError(
f"unsupported node type '{node_type}'",
extend_path(path, "type"),
) from exc
description = optional_str(mapping, "description", path)
# keep_context = bool(mapping.get("keep_context", False))
log_output = bool(mapping.get("log_output", True))
context_window = int(mapping.get("context_window", 0))
input_value = ensure_list(mapping.get("input"))
output_value = ensure_list(mapping.get("output"))
input_messages: List[Message] = []
for value in input_value:
if isinstance(value, dict) and "role" in value:
input_messages.append(Message.from_dict(value))
elif isinstance(value, Message):
input_messages.append(value)
else:
input_messages.append(Message(role=MessageRole.USER, content=str(value)))
if "config" not in mapping or mapping["config"] is None:
raise ConfigError("node config block required", extend_path(path, "config"))
config_obj = schema.config_cls.from_dict(
mapping["config"], path=extend_path(path, "config")
)
formatted_output: List[NodePayload] = []
for value in output_value:
if isinstance(value, dict) and "role" in value:
formatted_output.append(Message.from_dict(value))
elif isinstance(value, Message):
formatted_output.append(value)
else:
formatted_output.append(
Message(role=MessageRole.ASSISTANT, content=str(value))
)
# Dynamic configuration parsing removed - dynamic is now on edges
node = cls(
id=node_id,
type=node_type,
description=description,
log_output=log_output,
input=input_messages,
output=formatted_output,
# keep_context=keep_context,
context_window=context_window,
vars={},
config=config_obj,
path=path,
)
node.validate()
return node
def append_input(self, message: Message) -> None:
self.input.append(message)
def append_output(self, payload: NodePayload) -> None:
self.output.append(payload)
def clear_input(self, *, preserve_kept: bool = False, context_window: int = 0) -> int:
"""Clear queued inputs according to the node's context window semantics."""
if not preserve_kept:
self.input = []
return len(self.input)
if context_window < 0:
return len(self.input)
if context_window == 0:
self.input = [message for message in self.input if getattr(message, "keep", False)]
return len(self.input)
# context_window > 0 => retain the newest messages up to the specified
# capacity, but never drop messages flagged with keep=True. Those kept
# messages still count toward the window, effectively consuming slots that
# would otherwise be available for non-kept inputs.
keep_count = sum(1 for message in self.input if getattr(message, "keep", False))
allowed_non_keep = max(0, context_window - keep_count)
non_keep_total = sum(1 for message in self.input if not getattr(message, "keep", False))
non_keep_to_drop = max(0, non_keep_total - allowed_non_keep)
trimmed_inputs: List[Message] = []
for message in self.input:
if getattr(message, "keep", False):
trimmed_inputs.append(message)
continue
if non_keep_to_drop > 0:
non_keep_to_drop -= 1
continue
trimmed_inputs.append(message)
self.input = trimmed_inputs
return len(self.input)
def clear_inputs_by_flag(self, *, drop_non_keep: bool, drop_keep: bool) -> Tuple[int, int]:
"""Clear queued inputs according to keep markers."""
if not drop_non_keep and not drop_keep:
return 0, 0
remaining: List[Message] = []
removed_non_keep = 0
removed_keep = 0
for message in self.input:
is_keep = message.keep
if is_keep and drop_keep:
removed_keep += 1
continue
if not is_keep and drop_non_keep:
removed_non_keep += 1
continue
remaining.append(message)
if removed_non_keep or removed_keep:
self.input = remaining
return removed_non_keep, removed_keep
def validate(self) -> None:
if not self.config:
raise ConfigError("node configuration missing", extend_path(self.path, "config"))
if hasattr(self.config, "validate"):
self.config.validate()
@property
def node_type(self) -> str:
return self.type
@property
def model_name(self) -> Optional[str]:
agent = self.as_config(AgentConfig)
if not agent:
return None
return agent.name
@property
def role(self) -> Optional[str]:
agent = self.as_config(AgentConfig)
if agent:
return agent.role
human = self.as_config(HumanConfig)
if human:
return human.description
return None
@property
def tools(self) -> List[Any]:
agent = self.as_config(AgentConfig)
if agent and agent.tooling:
all_tools: List[Any] = []
for tool_config in agent.tooling:
func_cfg = tool_config.as_config(FunctionToolConfig)
if func_cfg:
all_tools.extend(func_cfg.tools)
return all_tools
return []
@property
def memories(self) -> List[Any]:
agent = self.as_config(AgentConfig)
if agent:
return list(agent.memories)
return []
@property
def params(self) -> Dict[str, Any]:
agent = self.as_config(AgentConfig)
if agent:
return dict(agent.params)
return {}
@property
def base_url(self) -> Optional[str]:
agent = self.as_config(AgentConfig)
if agent:
return agent.base_url
return None
def add_successor(self, node: "Node", edge_config: Optional[Dict[str, Any]] = None) -> None:
if node not in self.successors:
self.successors.append(node)
payload = dict(edge_config or {})
existing = next((link for link in self._outgoing_edges if link.target is node), None)
trigger = bool(payload.get("trigger", True)) if payload else True
carry_data = bool(payload.get("carry_data", True)) if payload else True
keep_message = bool(payload.get("keep_message", False)) if payload else False
clear_context = bool(payload.get("clear_context", False)) if payload else False
clear_kept_context = bool(payload.get("clear_kept_context", False)) if payload else False
condition_config = payload.pop("condition_config", None)
if not isinstance(condition_config, EdgeConditionConfig):
raw_value = payload.get("condition", "true")
condition_config = EdgeConditionConfig.from_dict(
raw_value,
path=extend_path(self.path, f"edge[{self.id}->{node.id}].condition"),
)
condition_label = condition_config.display_label()
condition_type = condition_config.type
condition_serializable = condition_config.to_external_value()
process_config = payload.pop("process_config", None)
if process_config is None and payload.get("process") is not None:
process_config = EdgeProcessorConfig.from_dict(
payload.get("process"),
path=extend_path(self.path, f"edge[{self.id}->{node.id}].process"),
)
process_serializable = process_config.to_external_value() if isinstance(process_config, EdgeProcessorConfig) else None
process_type = process_config.type if isinstance(process_config, EdgeProcessorConfig) else None
process_label = process_config.display_label() if isinstance(process_config, EdgeProcessorConfig) else None
# Handle dynamic_config
dynamic_config = payload.pop("dynamic_config", None)
if dynamic_config is None and payload.get("dynamic") is not None:
dynamic_config = DynamicEdgeConfig.from_dict(
payload.get("dynamic"),
path=extend_path(self.path, f"edge[{self.id}->{node.id}].dynamic"),
)
payload["condition"] = condition_serializable
payload["condition_label"] = condition_label
payload["condition_type"] = condition_type
if process_serializable is not None:
payload["process"] = process_serializable
payload["process_label"] = process_label
payload["process_type"] = process_type
if existing:
existing.config.update(payload)
existing.trigger = trigger
existing.condition = condition_label
existing.condition_config = condition_config
existing.condition_type = condition_type
existing.carry_data = carry_data
existing.keep_message = keep_message
existing.clear_context = clear_context
existing.clear_kept_context = clear_kept_context
if isinstance(process_config, EdgeProcessorConfig):
existing.process_config = process_config
existing.process_type = process_type
else:
existing.process_config = None
existing.process_type = None
existing.dynamic_config = dynamic_config
else:
self._outgoing_edges.append(
EdgeLink(
target=node,
config=payload,
trigger=trigger,
condition=condition_label,
condition_config=condition_config,
condition_type=condition_type,
carry_data=carry_data,
keep_message=keep_message,
clear_context=clear_context,
clear_kept_context=clear_kept_context,
process_config=process_config if isinstance(process_config, EdgeProcessorConfig) else None,
process_type=process_type,
dynamic_config=dynamic_config,
)
)
def add_predecessor(self, node: "Node") -> None:
if node not in self.predecessors:
self.predecessors.append(node)
def iter_outgoing_edges(self) -> Iterable[EdgeLink]:
return tuple(self._outgoing_edges)
def find_outgoing_edge(self, node_id: str) -> EdgeLink | None:
for link in self._outgoing_edges:
if link.target.id == node_id:
return link
return None
def is_triggered(self) -> bool:
if self.start_triggered:
return True
for predecessor in self.predecessors:
for edge_link in predecessor.iter_outgoing_edges():
if edge_link.target is self and edge_link.trigger and edge_link.triggered:
return True
return False
def reset_triggers(self) -> None:
self.start_triggered = False
for predecessor in self.predecessors:
for edge_link in predecessor.iter_outgoing_edges():
if edge_link.target is self:
edge_link.triggered = False
def merge_vars(self, parent_vars: Optional[Dict[str, Any]] = None) -> Dict[str, Any]:
merged = dict(parent_vars or {})
merged.update(self.vars)
return merged
+32
View File
@@ -0,0 +1,32 @@
"""Configuration for passthrough nodes."""
from dataclasses import dataclass
from typing import Mapping, Any
from entity.configs.base import BaseConfig, ConfigFieldSpec, optional_bool, require_mapping
@dataclass
class PassthroughConfig(BaseConfig):
"""Configuration for passthrough nodes."""
only_last_message: bool = True
@classmethod
def from_dict(cls, data: Mapping[str, Any] | None, *, path: str) -> "PassthroughConfig":
if data is None:
return cls(only_last_message=True, path=path)
mapping = require_mapping(data, path)
only_last_message = optional_bool(mapping, "only_last_message", path, default=True)
return cls(only_last_message=only_last_message, path=path)
FIELD_SPECS = {
"only_last_message": ConfigFieldSpec(
name="only_last_message",
display_name="Only Last Message",
type_hint="bool",
required=False,
default=True,
description="If True, only pass the last received message. If False, pass all messages.",
),
}
+97
View File
@@ -0,0 +1,97 @@
"""Configuration for Python code execution nodes."""
import sys
from dataclasses import dataclass, field
from typing import Any, Dict, List, Mapping
from entity.configs.base import (
BaseConfig,
ConfigError,
ConfigFieldSpec,
ensure_list,
optional_dict,
optional_str,
require_mapping,
)
def _default_interpreter() -> str:
return sys.executable or "python3"
@dataclass
class PythonRunnerConfig(BaseConfig):
interpreter: str = field(default_factory=_default_interpreter)
args: List[str] = field(default_factory=list)
env: Dict[str, str] = field(default_factory=dict)
timeout_seconds: int = 60
encoding: str = "utf-8"
@classmethod
def from_dict(cls, data: Mapping[str, Any], *, path: str) -> "PythonRunnerConfig":
mapping = require_mapping(data, path)
interpreter = optional_str(mapping, "interpreter", path) or _default_interpreter()
args_raw = mapping.get("args")
args = [str(item) for item in ensure_list(args_raw)] if args_raw is not None else []
env = optional_dict(mapping, "env", path) or {}
timeout_value = mapping.get("timeout_seconds", 60)
if not isinstance(timeout_value, int) or timeout_value <= 0:
raise ConfigError("timeout_seconds must be a positive integer", f"{path}.timeout_seconds")
encoding = optional_str(mapping, "encoding", path) or "utf-8"
if not encoding:
raise ConfigError("encoding cannot be empty", f"{path}.encoding")
return cls(
interpreter=interpreter,
args=args,
env={str(key): str(value) for key, value in env.items()},
timeout_seconds=timeout_value,
encoding=encoding,
path=path,
)
FIELD_SPECS = {
"interpreter": ConfigFieldSpec(
name="interpreter",
display_name="Python Path",
type_hint="str",
required=False,
default=_default_interpreter(),
description="Python executable file path, defaults to current process interpreter",
advance=True,
),
"args": ConfigFieldSpec(
name="args",
display_name="Startup Parameters",
type_hint="list[str]",
required=False,
default=[],
description="Parameter list appended after interpreter",
advance=True,
),
"env": ConfigFieldSpec(
name="env",
display_name="Additional Environment Variables",
type_hint="dict[str, str]",
required=False,
default={},
description="Additional environment variables, will override process defaults",
advance=True,
),
"timeout_seconds": ConfigFieldSpec(
name="timeout_seconds",
display_name="Timeout (seconds)",
type_hint="int",
required=False,
default=60,
description="Script execution timeout (seconds)",
),
"encoding": ConfigFieldSpec(
name="encoding",
display_name="Output Encoding",
type_hint="str",
required=False,
default="utf-8",
description="Encoding used to parse stdout/stderr",
advance=True,
),
}
+176
View File
@@ -0,0 +1,176 @@
"""Agent skill configuration models."""
from dataclasses import dataclass, field, replace
from pathlib import Path
from typing import Any, Dict, List, Mapping
import yaml
from entity.configs.base import (
BaseConfig,
ConfigError,
ConfigFieldSpec,
EnumOption,
optional_bool,
extend_path,
require_mapping,
)
REPO_ROOT = Path(__file__).resolve().parents[3]
DEFAULT_SKILLS_ROOT = (REPO_ROOT / ".agents" / "skills").resolve()
def _discover_default_skills() -> List[tuple[str, str]]:
if not DEFAULT_SKILLS_ROOT.exists() or not DEFAULT_SKILLS_ROOT.is_dir():
return []
discovered: List[tuple[str, str]] = []
for candidate in sorted(DEFAULT_SKILLS_ROOT.iterdir()):
if not candidate.is_dir():
continue
skill_file = candidate / "SKILL.md"
if not skill_file.is_file():
continue
try:
frontmatter = _parse_frontmatter(skill_file)
except Exception:
continue
raw_name = frontmatter.get("name")
raw_description = frontmatter.get("description")
if not isinstance(raw_name, str) or not raw_name.strip():
continue
if not isinstance(raw_description, str) or not raw_description.strip():
continue
discovered.append((raw_name.strip(), raw_description.strip()))
return discovered
def _parse_frontmatter(skill_file: Path) -> Mapping[str, object]:
text = skill_file.read_text(encoding="utf-8")
if not text.startswith("---"):
raise ValueError("missing frontmatter")
lines = text.splitlines()
end_idx = None
for idx in range(1, len(lines)):
if lines[idx].strip() == "---":
end_idx = idx
break
if end_idx is None:
raise ValueError("missing closing delimiter")
payload = "\n".join(lines[1:end_idx])
data = yaml.safe_load(payload) or {}
if not isinstance(data, Mapping):
raise ValueError("frontmatter must be a mapping")
return data
@dataclass
class AgentSkillSelectionConfig(BaseConfig):
name: str
FIELD_SPECS = {
"name": ConfigFieldSpec(
name="name",
display_name="Skill Name",
type_hint="str",
required=True,
description="Discovered skill name from the default repo-level skills directory.",
),
}
@classmethod
def from_dict(cls, data: Mapping[str, Any], *, path: str) -> "AgentSkillSelectionConfig":
mapping = require_mapping(data, path)
name = mapping.get("name")
if not isinstance(name, str) or not name.strip():
raise ConfigError("skill name is required", extend_path(path, "name"))
return cls(name=name.strip(), path=path)
@classmethod
def field_specs(cls) -> Dict[str, ConfigFieldSpec]:
specs = super().field_specs()
name_spec = specs.get("name")
if name_spec is None:
return specs
discovered = _discover_default_skills()
enum_values = [name for name, _ in discovered] or None
enum_options = [
EnumOption(value=name, label=name, description=description)
for name, description in discovered
] or None
description = name_spec.description or "Skill name"
if not discovered:
description = (
f"{description} (no skills found in {DEFAULT_SKILLS_ROOT})"
)
else:
description = (
f"{description} Picker options come from {DEFAULT_SKILLS_ROOT}."
)
specs["name"] = replace(
name_spec,
enum=enum_values,
enum_options=enum_options,
description=description,
)
return specs
@dataclass
class AgentSkillsConfig(BaseConfig):
enabled: bool = False
allow: List[str] = field(default_factory=list)
FIELD_SPECS = {
"enabled": ConfigFieldSpec(
name="enabled",
display_name="Enable Skills",
type_hint="bool",
required=False,
default=False,
description="Enable Agent Skills discovery and the built-in skill tools for this agent.",
advance=True,
),
"allow": ConfigFieldSpec(
name="allow",
display_name="Allowed Skills",
type_hint="list[AgentSkillSelectionConfig]",
required=False,
description="Optional allowlist of discovered skill names. Leave empty to expose every discovered skill.",
child=AgentSkillSelectionConfig,
advance=True,
),
}
@classmethod
def from_dict(cls, data: Mapping[str, Any], *, path: str) -> "AgentSkillsConfig":
mapping = require_mapping(data, path)
enabled = optional_bool(mapping, "enabled", path, default=False)
if enabled is None:
enabled = False
allow = cls._coerce_allow_entries(mapping.get("allow"), field_path=extend_path(path, "allow"))
return cls(enabled=enabled, allow=allow, path=path)
@staticmethod
def _coerce_allow_entries(value: Any, *, field_path: str) -> List[str]:
if value is None:
return []
if not isinstance(value, list):
raise ConfigError("expected list of skill entries", field_path)
result: List[str] = []
for idx, item in enumerate(value):
item_path = f"{field_path}[{idx}]"
if isinstance(item, str):
normalized = item.strip()
if normalized:
result.append(normalized)
continue
if isinstance(item, Mapping):
entry = AgentSkillSelectionConfig.from_dict(item, path=item_path)
result.append(entry.name)
continue
raise ConfigError("expected skill entry mapping or string", item_path)
return result
+266
View File
@@ -0,0 +1,266 @@
"""Subgraph node configuration and registry helpers."""
from dataclasses import dataclass, replace
from typing import Any, Dict, Mapping
from entity.enums import LogLevel
from entity.enum_options import enum_options_for, enum_options_from_values
from entity.configs.base import (
BaseConfig,
ConfigError,
ConfigFieldSpec,
ChildKey,
require_mapping,
require_str,
extend_path,
)
from entity.configs.edge.edge import EdgeConfig
from entity.configs.node.memory import MemoryStoreConfig
from utils.registry import Registry, RegistryError
subgraph_source_registry = Registry("subgraph_source")
def register_subgraph_source(
name: str,
*,
config_cls: type[BaseConfig],
description: str | None = None,
) -> None:
"""Register a subgraph source configuration class."""
metadata = {"summary": description} if description else None
subgraph_source_registry.register(name, target=config_cls, metadata=metadata)
def get_subgraph_source_config(name: str) -> type[BaseConfig]:
entry = subgraph_source_registry.get(name)
config_cls = entry.load()
if not isinstance(config_cls, type) or not issubclass(config_cls, BaseConfig):
raise RegistryError(f"Entry '{name}' is not a BaseConfig subclass")
return config_cls
def iter_subgraph_source_registrations() -> Dict[str, type[BaseConfig]]:
return {name: entry.load() for name, entry in subgraph_source_registry.items()}
def iter_subgraph_source_metadata() -> Dict[str, Dict[str, Any]]:
return {name: dict(entry.metadata or {}) for name, entry in subgraph_source_registry.items()}
@dataclass
class SubgraphFileConfig(BaseConfig):
file_path: str
@classmethod
def from_dict(cls, data: Mapping[str, Any], *, path: str) -> "SubgraphFileConfig":
mapping = require_mapping(data, path)
file_path = require_str(mapping, "path", path)
return cls(file_path=file_path, path=path)
FIELD_SPECS = {
"path": ConfigFieldSpec(
name="path",
display_name="Subgraph File Path",
type_hint="str",
required=True,
description="Subgraph file path (relative to yaml_instance/ or absolute path)",
),
}
@dataclass
class SubgraphInlineConfig(BaseConfig):
graph: Dict[str, Any]
@classmethod
def from_dict(cls, data: Mapping[str, Any], *, path: str) -> "SubgraphInlineConfig":
mapping = require_mapping(data, path)
return cls(graph=dict(mapping), path=path)
def validate(self) -> None:
if "nodes" not in self.graph:
raise ConfigError("subgraph config must define nodes", extend_path(self.path, "nodes"))
if "edges" not in self.graph:
raise ConfigError("subgraph config must define edges", extend_path(self.path, "edges"))
FIELD_SPECS = {
"id": ConfigFieldSpec(
name="id",
display_name="Subgraph ID",
type_hint="str",
required=True,
description="Subgraph identifier",
),
"description": ConfigFieldSpec(
name="description",
display_name="Subgraph Description",
type_hint="str",
required=False,
description="Describe the subgraph's responsibility, trigger conditions, and success criteria so reviewers know when to call it.",
),
"log_level": ConfigFieldSpec(
name="log_level",
display_name="Log Level",
type_hint="enum:LogLevel",
required=False,
default=LogLevel.INFO.value,
enum=[lvl.value for lvl in LogLevel],
description="Subgraph runtime log level",
enum_options=enum_options_for(LogLevel),
),
"is_majority_voting": ConfigFieldSpec(
name="is_majority_voting",
display_name="Majority Voting",
type_hint="bool",
required=False,
default=False,
description="Whether to perform majority voting on node results",
),
"nodes": ConfigFieldSpec(
name="nodes",
display_name="Node List",
type_hint="list[Node]",
required=True,
description="Subgraph node list, must contain at least one node",
),
"edges": ConfigFieldSpec(
name="edges",
display_name="Edge List",
type_hint="list[EdgeConfig]",
required=True,
description="Subgraph edge list",
child=EdgeConfig,
),
"memory": ConfigFieldSpec(
name="memory",
display_name="Memory Stores",
type_hint="list[MemoryStoreConfig]",
required=False,
description="Provide a list of memory stores if this subgraph needs dedicated stores; leave empty to inherit parent graph stores.",
child=MemoryStoreConfig,
),
"vars": ConfigFieldSpec(
name="vars",
display_name="Variables",
type_hint="dict[str, Any]",
required=False,
default={},
description="Variables passed to subgraph nodes",
),
"organization": ConfigFieldSpec(
name="organization",
display_name="Organization",
type_hint="str",
required=False,
description="Subgraph organization/team identifier",
),
"initial_instruction": ConfigFieldSpec(
name="initial_instruction",
display_name="Initial Instruction",
type_hint="str",
required=False,
description="Subgraph level initial instruction",
),
"start": ConfigFieldSpec(
name="start",
display_name="Start Node",
type_hint="str | list[str]",
required=False,
description="Start node ID list (entry list executed at subgraph start; not recommended to edit manually)",
),
"end": ConfigFieldSpec(
name="end",
display_name="End Node",
type_hint="str | list[str]",
required=False,
description="End node ID list (used to collect final subgraph output, not part of execution logic). This is an ordered list: earlier nodes are checked first; the first with output becomes the subgraph output, otherwise continue down the list.",
),
}
@classmethod
def field_specs(cls) -> Dict[str, ConfigFieldSpec]:
specs = super().field_specs()
nodes_spec = specs.get("nodes")
if nodes_spec:
from entity.configs.node.node import Node
specs["nodes"] = replace(nodes_spec, child=Node)
return specs
@dataclass
class SubgraphConfig(BaseConfig):
type: str
config: BaseConfig | None = None
@classmethod
def from_dict(cls, data: Mapping[str, Any], *, path: str) -> "SubgraphConfig":
mapping = require_mapping(data, path)
source_type = require_str(mapping, "type", path)
if "vars" in mapping and mapping["vars"]:
raise ConfigError("vars is only allowed at root level (DesignConfig.vars)", extend_path(path, "vars"))
if "config" not in mapping or mapping["config"] is None:
raise ConfigError("subgraph configuration requires 'config' block", extend_path(path, "config"))
try:
config_cls = get_subgraph_source_config(source_type)
except RegistryError as exc:
raise ConfigError(
f"subgraph.type must be one of {list(iter_subgraph_source_registrations().keys())}",
extend_path(path, "type"),
) from exc
config_obj = config_cls.from_dict(mapping["config"], path=extend_path(path, "config"))
return cls(type=source_type, config=config_obj, path=path)
def validate(self) -> None:
if not self.config:
raise ConfigError("subgraph config missing", extend_path(self.path, "config"))
if hasattr(self.config, "validate"):
self.config.validate()
FIELD_SPECS = {
"type": ConfigFieldSpec(
name="type",
display_name="Subgraph Source Type",
type_hint="str",
required=True,
description="Registered subgraph source such as 'config' or 'file' (see subgraph_source_registry).",
),
"config": ConfigFieldSpec(
name="config",
display_name="Subgraph Configuration",
type_hint="object",
required=True,
description="Payload interpreted by the chosen type—for example inline graph schema for 'config' or file path payload for 'file'.",
),
}
@classmethod
def child_routes(cls) -> Dict[ChildKey, type[BaseConfig]]:
return {
ChildKey(field="config", value=name): config_cls
for name, config_cls in iter_subgraph_source_registrations().items()
}
@classmethod
def field_specs(cls) -> Dict[str, ConfigFieldSpec]:
specs = super().field_specs()
type_spec = specs.get("type")
if type_spec:
registrations = iter_subgraph_source_registrations()
metadata = iter_subgraph_source_metadata()
names = list(registrations.keys())
descriptions = {
name: (metadata.get(name) or {}).get("summary") for name in names
}
specs["type"] = replace(
type_spec,
enum=names,
enum_options=enum_options_from_values(names, descriptions, preserve_label_case=True),
)
return specs
+94
View File
@@ -0,0 +1,94 @@
"""Thinking configuration models."""
from dataclasses import dataclass, replace
from typing import Any, Dict, Mapping
from entity.enum_options import enum_options_from_values
from schema_registry import (
SchemaLookupError,
get_thinking_schema,
iter_thinking_schemas,
)
from entity.configs.base import BaseConfig, ConfigError, ConfigFieldSpec, ChildKey, extend_path, require_mapping, require_str
@dataclass
class ReflectionThinkingConfig(BaseConfig):
reflection_prompt: str
@classmethod
def from_dict(cls, data: Mapping[str, Any], *, path: str) -> "ReflectionThinkingConfig":
mapping = require_mapping(data, path)
prompt = require_str(mapping, "reflection_prompt", path)
return cls(reflection_prompt=prompt, path=path)
FIELD_SPECS = {
"reflection_prompt": ConfigFieldSpec(
name="reflection_prompt",
display_name="Reflection Prompt",
type_hint="str",
required=True,
description="Prompt used for reflection in reflection mode",
)
}
@dataclass
class ThinkingConfig(BaseConfig):
type: str
config: BaseConfig | None = None
@classmethod
def from_dict(cls, data: Mapping[str, Any], *, path: str) -> "ThinkingConfig":
mapping = require_mapping(data, path)
thinking_type = require_str(mapping, "type", path)
try:
schema = get_thinking_schema(thinking_type)
except SchemaLookupError as exc:
raise ConfigError(f"unsupported thinking type '{thinking_type}'", extend_path(path, "type")) from exc
if "config" not in mapping or mapping["config"] is None:
raise ConfigError("thinking config requires config block", extend_path(path, "config"))
config_obj = schema.config_cls.from_dict(mapping["config"], path=extend_path(path, "config"))
return cls(type=thinking_type, config=config_obj, path=path)
FIELD_SPECS = {
"type": ConfigFieldSpec(
name="type",
display_name="Thinking Mode",
type_hint="str",
required=True,
description="Thinking mode type",
),
"config": ConfigFieldSpec(
name="config",
display_name="Thinking Configuration",
type_hint="object",
required=True,
description="Thinking mode configuration body",
),
}
@classmethod
def child_routes(cls) -> dict[ChildKey, type[BaseConfig]]:
return {
ChildKey(field="config", value=name): schema.config_cls
for name, schema in iter_thinking_schemas().items()
}
@classmethod
def field_specs(cls) -> Dict[str, ConfigFieldSpec]:
specs = super().field_specs()
type_spec = specs.get("type")
if type_spec:
registrations = iter_thinking_schemas()
names = list(registrations.keys())
descriptions = {name: schema.summary for name, schema in registrations.items()}
specs["type"] = replace(
type_spec,
enum=names,
enum_options=enum_options_from_values(names, descriptions, preserve_label_case=True),
)
return specs
+660
View File
@@ -0,0 +1,660 @@
"""Tooling configuration models."""
import hashlib
from copy import deepcopy
from dataclasses import dataclass, field, replace
from typing import Any, Dict, List, Mapping, Tuple
from entity.configs.base import (
BaseConfig,
ConfigError,
ConfigFieldSpec,
EnumOption,
ChildKey,
ensure_list,
optional_bool,
optional_str,
require_mapping,
require_str,
extend_path,
)
from entity.enum_options import enum_options_from_values
from utils.registry import Registry, RegistryError
from utils.function_catalog import FunctionCatalog, get_function_catalog
tooling_type_registry = Registry("tooling_type")
MODULE_ALL_SUFFIX = ":All"
def register_tooling_type(
name: str,
*,
config_cls: type[BaseConfig],
description: str | None = None,
) -> None:
metadata = {"summary": description} if description else None
tooling_type_registry.register(name, target=config_cls, metadata=metadata)
def get_tooling_type_config(name: str) -> type[BaseConfig]:
entry = tooling_type_registry.get(name)
config_cls = entry.load()
if not isinstance(config_cls, type) or not issubclass(config_cls, BaseConfig):
raise RegistryError(f"Entry '{name}' is not a BaseConfig subclass")
return config_cls
def iter_tooling_type_registrations() -> Dict[str, type[BaseConfig]]:
return {name: entry.load() for name, entry in tooling_type_registry.items()}
def iter_tooling_type_metadata() -> Dict[str, Dict[str, Any]]:
return {name: dict(entry.metadata or {}) for name, entry in tooling_type_registry.items()}
@dataclass
class FunctionToolEntryConfig(BaseConfig):
"""Schema helper used to describe per-function options."""
name: str | None = None
description: str | None = None
parameters: Dict[str, Any] | None = None
auto_fill: bool = True
FIELD_SPECS = {
"name": ConfigFieldSpec(
name="name",
display_name="Function Name",
type_hint="str",
required=True,
description="Function name from functions/function_calling directory",
),
# "description": ConfigFieldSpec(
# name="description",
# display_name="Description",
# type_hint="str",
# required=False,
# description="Override auto-parsed function description, optional",
# advance=True,
# ),
# "parameters": ConfigFieldSpec(
# name="parameters",
# display_name="Parameter Schema",
# type_hint="object",
# required=False,
# description="Override JSON Schema generated from function signature, optional",
# advance=True,
# ),
# "auto_fill": ConfigFieldSpec(
# name="auto_fill",
# display_name="Auto Fill Description",
# type_hint="bool",
# required=False,
# default=True,
# description="Whether to auto-fill description/parameters based on Python function signature",
# advance=True,
# ),
}
@classmethod
def field_specs(cls) -> Dict[str, ConfigFieldSpec]:
specs = super().field_specs()
catalog = get_function_catalog()
modules = catalog.iter_modules()
name_spec = specs.get("name")
if name_spec is not None:
description = name_spec.description or "Function name"
enum_options: List[EnumOption] | None = None
enum_values: List[str] | None = None
if catalog.load_error:
description = f"{description} (loading failed: {catalog.load_error})"
elif not modules:
description = f"{description} (no functions found in directory)"
else:
enum_options = []
enum_values = []
for module_name, metas in modules:
all_label = f"{module_name}{MODULE_ALL_SUFFIX}"
enum_values.append(all_label)
preview = ", ".join(meta.name for meta in metas[:3])
suffix = "..." if len(metas) > 3 else ""
module_hint = f"{module_name}.py"
enum_options.append(
EnumOption(
value=all_label,
label=all_label,
description=(
f"Load all {len(metas)} functions from {module_hint}"
+ (f" ({preview}{suffix})" if preview else "")
),
)
)
for module_name, metas in modules:
for meta in metas:
label = f"{module_name}:{meta.name}"
enum_values.append(meta.name)
option_description = meta.description or "This function does not provide a docstring"
enum_options.append(
EnumOption(
value=meta.name,
label=label,
description=option_description,
)
)
specs["name"] = replace(
name_spec,
enum=enum_values,
enum_options=enum_options,
description=description,
)
return specs
@dataclass
class FunctionToolConfig(BaseConfig):
tools: List[Dict[str, Any]]
auto_load: bool = True
timeout: float | None = None
# schema_version: str | None = None
FIELD_SPECS = {
"tools": ConfigFieldSpec(
name="tools",
display_name="Function Tool List",
type_hint="list[FunctionToolEntryConfig]",
required=True,
description="Function tool list, at least one item",
child=FunctionToolEntryConfig,
),
# "auto_load": ConfigFieldSpec(
# name="auto_load",
# display_name="Auto Load Directory",
# type_hint="bool",
# required=False,
# default=True,
# description="Auto-load functions directory on startup",
# advance=True
# ),
"timeout": ConfigFieldSpec(
name="timeout",
display_name="Execution Timeout",
type_hint="float",
required=False,
description="Tool execution timeout (seconds)",
advance=True
),
# "schema_version": ConfigFieldSpec(
# name="schema_version",
# display_name="Schema Version",
# type_hint="str",
# required=False,
# description="Tool schema version",
# ),
}
@classmethod
def from_dict(cls, data: Mapping[str, Any], *, path: str) -> "FunctionToolConfig":
mapping = require_mapping(data, path)
tools = ensure_list(mapping.get("tools"))
if not tools:
raise ConfigError("tools must be provided for function tooling", extend_path(path, "tools"))
catalog = get_function_catalog()
expanded_tools: List[Tuple[Dict[str, Any], str]] = []
for idx, tool in enumerate(tools):
tool_path = extend_path(path, f"tools[{idx}]")
if not isinstance(tool, Mapping):
raise ConfigError("tool entry must be a mapping", tool_path)
normalized = dict(tool)
raw_name = normalized.get("name")
if not isinstance(raw_name, str) or not raw_name.strip():
raise ConfigError("tool name is required", extend_path(tool_path, "name"))
name = raw_name.strip()
normalized["name"] = name
module_name = cls._extract_module_from_all(name)
if module_name:
expanded_tools.extend(
cls._expand_module_all_entry(
module_name=module_name,
catalog=catalog,
path=tool_path,
original=normalized,
)
)
continue
expanded_tools.append((normalized, tool_path))
tool_specs: List[Dict[str, Any]] = []
seen_functions: Dict[str, str] = {}
for entry, entry_path in expanded_tools:
normalized = dict(entry)
name = normalized.get("name")
if not isinstance(name, str) or not name.strip():
raise ConfigError("tool name is required", extend_path(entry_path, "name"))
metadata = catalog.get(name)
if metadata is None:
raise ConfigError(
f"function '{name}' not found under function directory",
extend_path(entry_path, "name"),
)
previous = seen_functions.get(name)
if previous is not None:
raise ConfigError(
f"function '{name}' is declared multiple times (also in {previous})",
extend_path(entry_path, "name"),
)
seen_functions[name] = entry_path
auto_fill = normalized.get("auto_fill", True)
if not isinstance(auto_fill, bool):
raise ConfigError("auto_fill must be boolean", extend_path(entry_path, "auto_fill"))
merged = dict(normalized)
if auto_fill:
if not merged.get("description") and metadata.description:
merged["description"] = metadata.description
if not merged.get("parameters"):
merged["parameters"] = deepcopy(metadata.parameters_schema)
merged.pop("auto_fill", None)
tool_specs.append(merged)
auto_load = optional_bool(mapping, "auto_load", path, default=True)
timeout_value = mapping.get("timeout")
if timeout_value is not None and not isinstance(timeout_value, (int, float)):
raise ConfigError("timeout must be numeric", extend_path(path, "timeout"))
# schema_version = optional_str(mapping, "schema_version", path)
return cls(
tools=tool_specs,
auto_load=bool(auto_load) if auto_load is not None else True,
timeout=float(timeout_value) if isinstance(timeout_value, (int, float)) else None,
# schema_version=schema_version,
path=path,
)
@staticmethod
def _extract_module_from_all(value: str) -> str | None:
if not value.endswith(MODULE_ALL_SUFFIX):
return None
module = value[: -len(MODULE_ALL_SUFFIX)].strip()
return module or None
@staticmethod
def _expand_module_all_entry(
*,
module_name: str,
catalog: FunctionCatalog,
path: str,
original: Mapping[str, Any],
) -> List[Tuple[Dict[str, Any], str]]:
disallowed = [key for key in ("description", "parameters", "auto_fill") if key in original]
if disallowed:
fields = ", ".join(disallowed)
raise ConfigError(
f"{module_name}{MODULE_ALL_SUFFIX} does not support overriding {fields}",
extend_path(path, "name"),
)
functions = catalog.functions_for_module(module_name)
if not functions:
raise ConfigError(
f"module '{module_name}' has no functions under function directory",
extend_path(path, "name"),
)
entries: List[Tuple[Dict[str, Any], str]] = []
for fn_name in functions:
entries.append(({"name": fn_name}, path))
return entries
@dataclass
class McpRemoteConfig(BaseConfig):
server: str
headers: Dict[str, str] = field(default_factory=dict)
timeout: float | None = None
cache_ttl: float = 0.0
tool_sources: List[str] | None = None
FIELD_SPECS = {
"server": ConfigFieldSpec(
name="server",
display_name="MCP Server URL",
type_hint="str",
required=True,
description="HTTP(S) endpoint of the MCP server",
),
"headers": ConfigFieldSpec(
name="headers",
display_name="Custom Headers",
type_hint="dict[str, str]",
required=False,
description="Additional request headers (e.g. Authorization)",
advance=True,
),
"timeout": ConfigFieldSpec(
name="timeout",
display_name="Client Timeout",
type_hint="float",
required=False,
description="Per-request timeout in seconds",
advance=True,
),
"cache_ttl": ConfigFieldSpec(
name="cache_ttl",
display_name="Tool Cache TTL",
type_hint="float",
required=False,
description="Seconds to cache MCP tool list; 0 disables cache for hot updates",
advance=True,
),
"tool_sources": ConfigFieldSpec(
name="tool_sources",
display_name="Tool Sources Filter",
type_hint="list[str]",
required=False,
description="Only include MCP tools whose meta.source is in this list; omit to default to ['mcp_tools'].",
advance=True,
),
}
@classmethod
def from_dict(cls, data: Mapping[str, Any], *, path: str) -> "McpRemoteConfig":
mapping = require_mapping(data, path)
server = require_str(mapping, "server", path)
headers_raw = mapping.get("headers")
headers: Dict[str, str] = {}
if headers_raw is not None:
if not isinstance(headers_raw, Mapping):
raise ConfigError("headers must be a mapping", extend_path(path, "headers"))
headers = {str(k): str(v) for k, v in headers_raw.items()}
timeout_value = mapping.get("timeout")
timeout: float | None
if timeout_value is None:
timeout = None
elif isinstance(timeout_value, (int, float)):
timeout = float(timeout_value)
else:
raise ConfigError("timeout must be numeric", extend_path(path, "timeout"))
cache_ttl_value = mapping.get("cache_ttl", 0.0)
if cache_ttl_value is None:
cache_ttl = 0.0
elif isinstance(cache_ttl_value, (int, float)):
cache_ttl = float(cache_ttl_value)
else:
raise ConfigError("cache_ttl must be numeric", extend_path(path, "cache_ttl"))
tool_sources_raw = mapping.get("tool_sources")
tool_sources: List[str] | None = None
if tool_sources_raw is not None:
entries = ensure_list(tool_sources_raw)
normalized: List[str] = []
for idx, entry in enumerate(entries):
if not isinstance(entry, str):
raise ConfigError(
"tool_sources must be a list of strings",
extend_path(path, f"tool_sources[{idx}]"),
)
value = entry.strip()
if value:
normalized.append(value)
tool_sources = normalized
else:
tool_sources = ["mcp_tools"]
return cls(
server=server,
headers=headers,
timeout=timeout,
cache_ttl=cache_ttl,
tool_sources=tool_sources,
path=path,
)
def cache_key(self) -> str:
payload = (
self.server,
tuple(sorted(self.headers.items())),
self.timeout,
)
return hashlib.sha1(repr(payload).encode("utf-8")).hexdigest()
@dataclass
class McpLocalConfig(BaseConfig):
command: str
args: List[str] = field(default_factory=list)
cwd: str | None = None
env: Dict[str, str] = field(default_factory=dict)
inherit_env: bool = True
startup_timeout: float = 10.0
wait_for_log: str | None = None
cache_ttl: float = 0.0
FIELD_SPECS = {
"command": ConfigFieldSpec(
name="command",
display_name="Launch Command",
type_hint="str",
required=True,
description="Executable used to start the MCP stdio server (e.g. uvx)",
),
"args": ConfigFieldSpec(
name="args",
display_name="Arguments",
type_hint="list[str]",
required=False,
description="Command arguments, defaults to empty list",
),
"cwd": ConfigFieldSpec(
name="cwd",
display_name="Working Directory",
type_hint="str",
required=False,
description="Optional working directory for the launch command",
advance=True,
),
"env": ConfigFieldSpec(
name="env",
display_name="Environment Variables",
type_hint="dict[str, str]",
required=False,
description="Additional environment variables for the process",
advance=True,
),
"inherit_env": ConfigFieldSpec(
name="inherit_env",
display_name="Inherit Parent Env",
type_hint="bool",
required=False,
default=True,
description="Whether to start from parent env before applying overrides",
advance=True,
),
"startup_timeout": ConfigFieldSpec(
name="startup_timeout",
display_name="Startup Timeout",
type_hint="float",
required=False,
default=10.0,
description="Seconds to wait for readiness logs",
advance=True,
),
"wait_for_log": ConfigFieldSpec(
name="wait_for_log",
display_name="Ready Log Pattern",
type_hint="str",
required=False,
description="Regex that marks readiness when matched against stdout",
advance=True,
),
"cache_ttl": ConfigFieldSpec(
name="cache_ttl",
display_name="Tool Cache TTL",
type_hint="float",
required=False,
description="Seconds to cache MCP tool list; 0 disables cache for hot updates",
advance=True,
),
}
@classmethod
def from_dict(cls, data: Mapping[str, Any], *, path: str) -> "McpLocalConfig":
mapping = require_mapping(data, path)
command = require_str(mapping, "command", path)
args_raw = ensure_list(mapping.get("args"))
normalized_args: List[str] = []
for idx, arg in enumerate(args_raw):
arg_path = extend_path(path, f"args[{idx}]")
if not isinstance(arg, str):
raise ConfigError("args entries must be strings", arg_path)
normalized_args.append(arg)
cwd = optional_str(mapping, "cwd", path)
inherit_env = optional_bool(mapping, "inherit_env", path, default=True)
if inherit_env is None:
inherit_env = True
env_mapping = mapping.get("env")
if env_mapping is not None:
if not isinstance(env_mapping, Mapping):
raise ConfigError("env must be a mapping", extend_path(path, "env"))
env = {str(k): str(v) for k, v in env_mapping.items()}
else:
env = {}
timeout_value = mapping.get("startup_timeout", 10.0)
if timeout_value is None:
startup_timeout = 10.0
elif isinstance(timeout_value, (int, float)):
startup_timeout = float(timeout_value)
else:
raise ConfigError("startup_timeout must be numeric", extend_path(path, "startup_timeout"))
wait_for_log = optional_str(mapping, "wait_for_log", path)
cache_ttl_value = mapping.get("cache_ttl", 0.0)
if cache_ttl_value is None:
cache_ttl = 0.0
elif isinstance(cache_ttl_value, (int, float)):
cache_ttl = float(cache_ttl_value)
else:
raise ConfigError("cache_ttl must be numeric", extend_path(path, "cache_ttl"))
return cls(
command=command,
args=normalized_args,
cwd=cwd,
env=env,
inherit_env=bool(inherit_env),
startup_timeout=startup_timeout,
wait_for_log=wait_for_log,
cache_ttl=cache_ttl,
path=path,
)
def cache_key(self) -> str:
payload = (
self.command,
tuple(self.args),
self.cwd or "",
tuple(sorted(self.env.items())),
self.inherit_env,
self.startup_timeout,
self.wait_for_log or "",
)
return hashlib.sha1(repr(payload).encode("utf-8")).hexdigest()
register_tooling_type(
"function",
config_cls=FunctionToolConfig,
description="Use local Python functions",
)
register_tooling_type(
"mcp_remote",
config_cls=McpRemoteConfig,
description="Connect to an HTTP-based MCP server",
)
register_tooling_type(
"mcp_local",
config_cls=McpLocalConfig,
description="Launch and connect to a local stdio MCP server",
)
@dataclass
class ToolingConfig(BaseConfig):
type: str
config: BaseConfig | None = None
prefix: str | None = None
FIELD_SPECS = {
"type": ConfigFieldSpec(
name="type",
display_name="Tool Type",
type_hint="str",
required=True,
description="Select a tooling adapter registered via tooling_type_registry (function, mcp_remote, mcp_local, etc.).",
),
"prefix": ConfigFieldSpec(
name="prefix",
display_name="Tool Prefix",
type_hint="str",
required=False,
description="Optional prefix for all tools from this source to prevent name collisions (e.g. 'mcp1').",
advance=True,
),
"config": ConfigFieldSpec(
name="config",
display_name="Tool Configuration",
type_hint="object",
required=True,
description="Configuration block validated by the chosen tool type (Python function list, MCP server settings, local command MCP launch, etc.).",
),
}
@classmethod
def child_routes(cls) -> Dict[ChildKey, type[BaseConfig]]:
return {
ChildKey(field="config", value=name): config_cls
for name, config_cls in iter_tooling_type_registrations().items()
}
@classmethod
def from_dict(cls, data: Mapping[str, Any], *, path: str) -> "ToolingConfig":
mapping = require_mapping(data, path)
tooling_type = require_str(mapping, "type", path)
try:
config_cls = get_tooling_type_config(tooling_type)
except RegistryError as exc:
raise ConfigError(
f"tooling.type must be one of {list(iter_tooling_type_registrations().keys())}",
extend_path(path, "type"),
) from exc
config_payload = mapping.get("config")
if config_payload is None:
raise ConfigError("tooling requires config block", extend_path(path, "config"))
config_obj = config_cls.from_dict(config_payload, path=extend_path(path, "config"))
prefix = optional_str(mapping, "prefix", path)
return cls(type=tooling_type, config=config_obj, prefix=prefix, path=path)
@classmethod
def field_specs(cls) -> Dict[str, ConfigFieldSpec]:
specs = super().field_specs()
type_spec = specs.get("type")
if type_spec:
registrations = iter_tooling_type_registrations()
metadata = iter_tooling_type_metadata()
type_names = list(registrations.keys())
default_value = type_names[0] if type_names else None
descriptions = {name: (metadata.get(name) or {}).get("summary") for name in type_names}
specs["type"] = replace(
type_spec,
enum=type_names,
default=default_value,
enum_options=enum_options_from_values(type_names, descriptions),
)
return specs