from __future__ import annotations import re from enum import StrEnum from typing import Any, Final, Literal from pydantic import BaseModel, ConfigDict, Field, field_validator, model_validator class AgentKnowledgeQueryMode(StrEnum): USER_QUERY = "user_query" GENERATED_QUERY = "generated_query" class WorkflowNodeJobMode(StrEnum): LET_AGENT_FIGURE_IT_OUT = "let_agent_figure_it_out" TELL_AGENT_WHAT_TO_DO = "tell_agent_what_to_do" class DeclaredOutputType(StrEnum): STRING = "string" NUMBER = "number" OBJECT = "object" ARRAY = "array" BOOLEAN = "boolean" FILE = "file" class OutputErrorStrategy(StrEnum): """Per-output failure handling strategy. Mirrors ``graphon.ErrorStrategy`` but scoped to a single declared output of a Workflow Agent Node. The runtime applies the strategy after type check or output check fails and any configured retry attempts have been exhausted. """ STOP = "stop" DEFAULT_VALUE = "default_value" FAIL_BRANCH = "fail_branch" # JSON-schema-friendly name pattern. Stage 4 §3.1 / §10.1. _OUTPUT_NAME_PATTERN: Final[re.Pattern[str]] = re.compile(r"^[A-Za-z_][A-Za-z0-9_]*$") JsonPrimitive = str | int | float | bool | None RuntimeParameterValue = JsonPrimitive | list[str] | list[int] | list[float] | list[bool] class AgentFlexibleConfig(BaseModel): model_config = ConfigDict(extra="allow") def get(self, key: str, default: Any = None) -> Any: return self.model_dump(mode="python").get(key, default) def items(self): return self.model_dump(mode="python").items() def __bool__(self) -> bool: return bool(self.model_dump(mode="python", exclude_none=True, exclude_defaults=True)) class AgentFileRefConfig(AgentFlexibleConfig): id: str | None = Field(default=None, max_length=255) name: str | None = Field(default=None, max_length=255) type: str | None = Field(default=None, max_length=64) transfer_method: str | None = Field(default=None, max_length=64) url: str | None = None class AgentSkillRefConfig(AgentFlexibleConfig): id: str | None = Field(default=None, max_length=255) name: str | None = Field(default=None, max_length=255) description: str | None = None file_id: str | None = Field(default=None, max_length=255) path: str | None = None class AgentCliToolConfig(AgentFlexibleConfig): enabled: bool = True name: str | None = Field(default=None, max_length=255) description: str | None = None command: str | None = None class AgentKnowledgeDatasetConfig(AgentFlexibleConfig): id: str | None = Field(default=None, max_length=255) name: str | None = Field(default=None, max_length=255) description: str | None = None class AgentKnowledgeQueryConfig(AgentFlexibleConfig): query: str | None = None top_k: int | None = Field(default=None, ge=1) score_threshold: float | None = Field(default=None, ge=0, le=1) score_threshold_enabled: bool | None = None class AgentHumanContactConfig(AgentFlexibleConfig): id: str | None = Field(default=None, max_length=255) contact_id: str | None = Field(default=None, max_length=255) human_id: str | None = Field(default=None, max_length=255) name: str | None = Field(default=None, max_length=255) email: str | None = Field(default=None, max_length=255) class AgentHumanToolConfig(AgentFlexibleConfig): enabled: bool = True name: str | None = Field(default=None, max_length=255) description: str | None = None class AgentEnvVariableConfig(AgentFlexibleConfig): name: str | None = Field(default=None, max_length=255) type: str | None = Field(default=None, max_length=64) value: RuntimeParameterValue = None required: bool = False class AgentSecretRefConfig(AgentFlexibleConfig): name: str | None = Field(default=None, max_length=255) type: str | None = Field(default=None, max_length=64) id: str | None = Field(default=None, max_length=255) provider: str | None = Field(default=None, max_length=255) class AgentSandboxProviderConfig(AgentFlexibleConfig): image: str | None = None working_dir: str | None = None env: list[AgentEnvVariableConfig] = Field(default_factory=list) class AgentMemoryArtifactConfig(AgentFlexibleConfig): id: str | None = Field(default=None, max_length=255) type: str | None = Field(default=None, max_length=64) name: str | None = Field(default=None, max_length=255) url: str | None = None class AgentModelResponseFormatConfig(AgentFlexibleConfig): type: str | None = Field(default=None, max_length=64) class AgentSoulModelSettings(BaseModel): model_config = ConfigDict(extra="allow") temperature: float | None = None top_p: float | None = None presence_penalty: float | None = None frequency_penalty: float | None = None max_tokens: int | None = None stop: list[str] | None = None response_format: AgentModelResponseFormatConfig | None = None class AgentFeatureToggleConfig(AgentFlexibleConfig): enabled: bool = False class AgentTextToSpeechFeatureConfig(AgentFeatureToggleConfig): language: str | None = None voice: str | None = None autoPlay: str | None = None class AgentSuggestedQuestionsAfterAnswerFeatureConfig(AgentFeatureToggleConfig): prompt: str | None = None model: AgentSoulModelConfig | None = None class AgentModerationIOConfig(AgentFlexibleConfig): enabled: bool = False preset_response: str | None = None class AgentModerationProviderConfig(AgentFlexibleConfig): keywords: str | None = None api_based_extension_id: str | None = None inputs_config: AgentModerationIOConfig | None = None outputs_config: AgentModerationIOConfig | None = None class AgentSensitiveWordAvoidanceFeatureConfig(AgentFeatureToggleConfig): type: str | None = None config: AgentModerationProviderConfig | None = None class AgentSoulAppFeaturesConfig(AgentFlexibleConfig): opening_statement: str | None = None suggested_questions: list[str] | None = None suggested_questions_after_answer: AgentSuggestedQuestionsAfterAnswerFeatureConfig | None = None speech_to_text: AgentFeatureToggleConfig | None = None text_to_speech: AgentTextToSpeechFeatureConfig | None = None retriever_resource: AgentFeatureToggleConfig | None = None sensitive_word_avoidance: AgentSensitiveWordAvoidanceFeatureConfig | None = None class WorkflowPreviousNodeOutputRef(AgentFlexibleConfig): selector: list[JsonPrimitive] | None = None variable_selector: list[JsonPrimitive] | None = None value_selector: list[JsonPrimitive] | None = None node_id: str | None = Field(default=None, max_length=255) output: str | None = Field(default=None, max_length=255) name: str | None = Field(default=None, max_length=255) variable: str | None = Field(default=None, max_length=255) key: str | None = Field(default=None, max_length=255) class WorkflowNodeJobMetadata(BaseModel): model_config = ConfigDict(extra="allow") file_refs: list[AgentFileRefConfig] | None = None class AgentSoulPromptConfig(BaseModel): system_prompt: str = "" class AgentSoulSkillsFilesConfig(BaseModel): files: list[AgentFileRefConfig] = Field(default_factory=list) skills: list[AgentSkillRefConfig] = Field(default_factory=list) class AgentSoulDifyToolCredentialRef(BaseModel): """Reference to a stored Dify Plugin Tool credential. Secret values are resolved only at runtime. The legacy ``credential_id`` field is accepted by :class:`AgentSoulDifyToolConfig` and normalized here so old Agent tool payloads can be read while new payloads stay explicit. """ model_config = ConfigDict(extra="ignore") type: Literal["provider", "tool"] = "tool" id: str | None = Field(default=None, max_length=255) provider: str | None = Field(default=None, max_length=255) class AgentSoulDifyToolConfig(BaseModel): """One Dify Plugin Tool configured on Agent Soul. The API backend prepares this persisted product shape into ``DifyPluginToolConfig`` before sending a run request to Agent backend. ``provider_id`` keeps compatibility with existing Agent tool config payloads; new callers should send ``plugin_id`` + ``provider`` when available. """ # ``extra="ignore"`` (not ``"allow"``) so historical Agent Soul payloads # with unknown fields still load — but the extra keys are dropped instead # of silently riding along into ``model_dump``. New callers should send the # explicit schema fields below. model_config = ConfigDict(extra="ignore") enabled: bool = True # Dify Plugin Tools live behind the ``PLUGIN`` provider type. ``BUILT_IN`` / # ``WORKFLOW`` / ``API`` providers are not exposed to the Agent backend in # this layer — keep the default narrow so a missing field surfaces as # ``agent_tool_declaration_not_found`` against the correct provider table. provider_type: str = "plugin" provider_id: str | None = Field(default=None, max_length=255) plugin_id: str | None = Field(default=None, max_length=255) provider: str | None = Field(default=None, max_length=255) tool_name: str = Field(min_length=1, max_length=255) credential_type: Literal["api-key", "oauth2", "unauthorized"] = "api-key" credential_ref: AgentSoulDifyToolCredentialRef | None = None # Reserved for a future user-rename UX. Accepted but currently rejected at # validation time so frontend cannot silently believe a rename took effect # (see :meth:`_validate_provider_and_credentials`). name: str | None = Field(default=None, max_length=255) description: str | None = None runtime_parameters: dict[str, RuntimeParameterValue] = Field(default_factory=dict) @model_validator(mode="before") @classmethod def _normalize_legacy_payload(cls, value: Any) -> Any: if not isinstance(value, dict): return value normalized = dict(value) if normalized.get("provider_id") is None and isinstance(normalized.get("provider_name"), str): normalized["provider_id"] = normalized["provider_name"] if normalized.get("runtime_parameters") is None and isinstance(normalized.get("tool_parameters"), dict): normalized["runtime_parameters"] = normalized["tool_parameters"] if normalized.get("credential_ref") is None and normalized.get("credential_id"): normalized["credential_ref"] = { "type": "tool", "id": normalized.get("credential_id"), "provider": normalized.get("provider_id") or normalized.get("provider"), } return normalized @model_validator(mode="after") def _validate_provider_and_credentials(self) -> AgentSoulDifyToolConfig: if not self.provider_id and not (self.plugin_id and self.provider): raise ValueError("Dify tool requires provider_id or plugin_id + provider") if self.credential_type != "unauthorized" and (self.credential_ref is None or not self.credential_ref.id): raise ValueError("credential_ref.id is required for credentialed Dify tools") # ``name`` is reserved for a future user-rename UX. Until that lands # the model-visible name is forced to match ``tool_name``; reject # explicit values so a frontend bug surfaces immediately instead of # producing a silently-ignored override. if self.name is not None and self.name != self.tool_name: raise ValueError("name override is not yet supported; omit ``name`` or set it equal to ``tool_name``.") return self class AgentSoulToolsConfig(BaseModel): dify_tools: list[AgentSoulDifyToolConfig] = Field(default_factory=list) cli_tools: list[AgentCliToolConfig] = Field(default_factory=list) class AgentSoulKnowledgeConfig(BaseModel): datasets: list[AgentKnowledgeDatasetConfig] = Field(default_factory=list) query_mode: AgentKnowledgeQueryMode | None = None query_config: AgentKnowledgeQueryConfig = Field(default_factory=AgentKnowledgeQueryConfig) class AgentSoulHumanConfig(BaseModel): contacts: list[AgentHumanContactConfig] = Field(default_factory=list) tools: list[AgentHumanToolConfig] = Field(default_factory=list) class AgentSoulEnvConfig(BaseModel): variables: list[AgentEnvVariableConfig] = Field(default_factory=list) secret_refs: list[AgentSecretRefConfig] = Field(default_factory=list) class AgentSoulSandboxConfig(BaseModel): provider: str | None = None config: AgentSandboxProviderConfig = Field(default_factory=AgentSandboxProviderConfig) class AgentSoulMemoryConfig(BaseModel): scope: str | None = None budget: str | None = None artifacts: list[AgentMemoryArtifactConfig] = Field(default_factory=list) class AgentSoulModelCredentialRef(BaseModel): """Reference to model credentials resolved only at runtime.""" type: str = Field(min_length=1, max_length=64) id: str | None = Field(default=None, max_length=255) provider: str | None = Field(default=None, max_length=255) class AgentSoulModelConfig(BaseModel): """Stable model selection for Agent runtime without storing secret values.""" plugin_id: str = Field(min_length=1, max_length=255) model_provider: str = Field(min_length=1, max_length=255) model: str = Field(min_length=1, max_length=255) credential_ref: AgentSoulModelCredentialRef | None = None model_settings: AgentSoulModelSettings = Field(default_factory=AgentSoulModelSettings) class AppVariableConfig(BaseModel): name: str = Field(min_length=1, max_length=255) type: str = Field(min_length=1, max_length=64) required: bool = False default: Any = None class AgentSoulConfig(BaseModel): model_config = ConfigDict(extra="forbid") schema_version: int = 1 prompt: AgentSoulPromptConfig = Field(default_factory=AgentSoulPromptConfig) skills_files: AgentSoulSkillsFilesConfig = Field(default_factory=AgentSoulSkillsFilesConfig) tools: AgentSoulToolsConfig = Field(default_factory=AgentSoulToolsConfig) knowledge: AgentSoulKnowledgeConfig = Field(default_factory=AgentSoulKnowledgeConfig) human: AgentSoulHumanConfig = Field(default_factory=AgentSoulHumanConfig) env: AgentSoulEnvConfig = Field(default_factory=AgentSoulEnvConfig) sandbox: AgentSoulSandboxConfig = Field(default_factory=AgentSoulSandboxConfig) memory: AgentSoulMemoryConfig = Field(default_factory=AgentSoulMemoryConfig) model: AgentSoulModelConfig | None = None app_features: AgentSoulAppFeaturesConfig = Field(default_factory=AgentSoulAppFeaturesConfig) app_variables: list[AppVariableConfig] = Field(default_factory=list) misc_legacy: AgentSoulAppFeaturesConfig = Field(default_factory=AgentSoulAppFeaturesConfig) class DeclaredOutputFileConfig(BaseModel): """File-type output metadata. Both lists empty means "any file accepted".""" model_config = ConfigDict(extra="forbid") extensions: list[str] = Field(default_factory=list) mime_types: list[str] = Field(default_factory=list) class DeclaredArrayItem(BaseModel): """Per-item shape for an ``array``-typed declared output. PRD §OUTPUT 配置框 keeps arrays one level deep on first version; nested arrays are rejected so the runtime type checker and JSON Schema stay easy to reason about. Stage 4 §4.2. """ model_config = ConfigDict(extra="forbid") type: DeclaredOutputType description: str | None = None @model_validator(mode="after") def _reject_nested_array(self) -> DeclaredArrayItem: if self.type == DeclaredOutputType.ARRAY: raise ValueError("nested arrays are not supported as array_item.type") return self class DeclaredOutputCheckConfig(BaseModel): """File-output content check via a model-based comparison against a benchmark file. Per PRD §OUTPUT 配置框, output check is **file-only** and optional. Stage 4 §4.3. """ model_config = ConfigDict(extra="forbid") enabled: bool = False prompt: str | None = None benchmark_file_ref: AgentFileRefConfig | None = None # Reserved for stage 4.1: pick a different model than Agent Soul's for the check. # Stage 4 leaves this Optional and unused by FileOutputCheckExecutor. model_ref: AgentSoulModelConfig | None = None @model_validator(mode="after") def _require_prompt_and_benchmark_when_enabled(self) -> DeclaredOutputCheckConfig: if self.enabled: if not self.prompt or not self.prompt.strip(): raise ValueError("prompt is required when output check is enabled") if self.benchmark_file_ref is None: raise ValueError("benchmark_file_ref is required when output check is enabled") return self class DeclaredOutputRetryConfig(BaseModel): """Per-output retry configuration that mirrors ``graphon.RetryConfig`` shape.""" model_config = ConfigDict(extra="forbid") enabled: bool = False max_retries: int = Field(default=0, ge=0, le=10) retry_interval_ms: int = Field(default=0, ge=0, le=60_000) class DeclaredOutputFailureStrategy(BaseModel): """Per-output failure handling. A single strategy applies to both ``type_check`` and ``output_check`` failures (PRD does not distinguish them at the UX level). Stage 4 §4.4. """ model_config = ConfigDict(extra="forbid") retry: DeclaredOutputRetryConfig = Field(default_factory=DeclaredOutputRetryConfig) on_failure: OutputErrorStrategy = OutputErrorStrategy.STOP # When ``on_failure == DEFAULT_VALUE`` this value replaces the failed output. The # value's shape must match the owning ``DeclaredOutputConfig.type``; that match is # enforced at ``DeclaredOutputConfig`` level so the strategy stays type-agnostic. default_value: Any = None @model_validator(mode="after") def _require_default_value_when_default_strategy(self) -> DeclaredOutputFailureStrategy: if self.on_failure == OutputErrorStrategy.DEFAULT_VALUE and self.default_value is None: raise ValueError( "default_value must be provided when on_failure=default_value; None is reserved for 'not set'." ) return self class DeclaredOutputConfig(BaseModel): """One declared output of a Workflow Agent Node. Stage 4 normalizes the shape: ``check`` is singular (was ``checks: list`` in stage 3), and ``failure_strategy`` defaults to a populated value so runtime code can call ``output.failure_strategy.on_failure`` without None-guards. """ model_config = ConfigDict(extra="forbid") id: str | None = None name: str = Field(min_length=1, max_length=255) type: DeclaredOutputType description: str | None = None required: bool = True file: DeclaredOutputFileConfig | None = None array_item: DeclaredArrayItem | None = None check: DeclaredOutputCheckConfig | None = None failure_strategy: DeclaredOutputFailureStrategy = Field(default_factory=DeclaredOutputFailureStrategy) @field_validator("failure_strategy", mode="before") @classmethod def _coerce_none_failure_strategy(cls, value: Any) -> Any: # Backward compat: persisted JSON may carry ``failure_strategy: null``; # treat it as "use defaults". if value is None: return DeclaredOutputFailureStrategy() return value @model_validator(mode="after") def _validate_shape(self) -> DeclaredOutputConfig: if not _OUTPUT_NAME_PATTERN.fullmatch(self.name): raise ValueError( f"output name {self.name!r} must match {_OUTPUT_NAME_PATTERN.pattern} (JSON-schema-friendly identifier)" ) if self.type == DeclaredOutputType.FILE: if self.file is None: self.file = DeclaredOutputFileConfig() elif self.file is not None: raise ValueError("file metadata is only allowed for file outputs") if self.type == DeclaredOutputType.ARRAY: if self.array_item is None: # Backward compat for stage 3 fixtures: array without array_item # defaults to array, matching the prior JSON-Schema behavior. self.array_item = DeclaredArrayItem(type=DeclaredOutputType.OBJECT) elif self.array_item is not None: raise ValueError("array_item is only allowed when type is array") # Per PRD §OUTPUT 配置框: output check is file-only. if self.check is not None and self.check.enabled and self.type != DeclaredOutputType.FILE: raise ValueError("output check is only allowed for file outputs") # If the strategy is DEFAULT_VALUE, validate the default's shape against the # declared type so we fail at save-time rather than at runtime. strategy = self.failure_strategy if strategy.on_failure == OutputErrorStrategy.DEFAULT_VALUE and strategy.default_value is not None: self._assert_default_value_matches_type(strategy.default_value) return self def _assert_default_value_matches_type(self, value: Any) -> None: type_ = self.type if type_ == DeclaredOutputType.STRING: ok = isinstance(value, str) elif type_ == DeclaredOutputType.NUMBER: ok = isinstance(value, (int, float)) and not isinstance(value, bool) elif type_ == DeclaredOutputType.BOOLEAN: ok = isinstance(value, bool) elif type_ == DeclaredOutputType.OBJECT: ok = isinstance(value, dict) elif type_ == DeclaredOutputType.ARRAY: ok = isinstance(value, list) elif type_ == DeclaredOutputType.FILE: ok = isinstance(value, dict) and "file_id" in value else: ok = False if not ok: raise ValueError( f"default_value shape does not match output type {type_.value!r}: got {type(value).__name__}" ) # PRD §OUTPUT 配置框 0522 共识: "Output 如果没有配置,则 text, files, json" # The runtime injects these when ``declared_outputs`` is empty (stage 4 §4.1, D-3). # Not persisted; mutating this constant changes UI defaults globally. DEFAULT_DECLARED_OUTPUTS: Final[tuple[DeclaredOutputConfig, ...]] = ( DeclaredOutputConfig( name="text", type=DeclaredOutputType.STRING, required=False, description="Free-form text answer.", ), DeclaredOutputConfig( name="files", type=DeclaredOutputType.ARRAY, required=False, description="Files produced by the agent.", array_item=DeclaredArrayItem(type=DeclaredOutputType.FILE), ), DeclaredOutputConfig( name="json", type=DeclaredOutputType.OBJECT, required=False, description="Free-form JSON object.", ), ) def effective_declared_outputs( declared_outputs: list[DeclaredOutputConfig] | tuple[DeclaredOutputConfig, ...], ) -> tuple[DeclaredOutputConfig, ...]: """Return the outputs the runtime actually presents. Returns ``declared_outputs`` unchanged when non-empty, otherwise the PRD defaults from ``DEFAULT_DECLARED_OUTPUTS``. Shared helper so Composer load responses, runtime request builder, and the Node Output Inspector all use the same fallback (stage 4 §4.1, decision D-3). """ if declared_outputs: return tuple(declared_outputs) return DEFAULT_DECLARED_OUTPUTS class WorkflowNodeJobConfig(BaseModel): model_config = ConfigDict(extra="forbid") schema_version: int = 1 mode: WorkflowNodeJobMode = WorkflowNodeJobMode.TELL_AGENT_WHAT_TO_DO workflow_prompt: str = "" previous_node_output_refs: list[WorkflowPreviousNodeOutputRef] = Field(default_factory=list) declared_outputs: list[DeclaredOutputConfig] = Field(default_factory=list) human_contacts: list[AgentHumanContactConfig] = Field(default_factory=list) metadata: WorkflowNodeJobMetadata = Field(default_factory=WorkflowNodeJobMetadata)