Merge branch 'feat/agent-node-v2' into feat/support-agent-sandbox

This commit is contained in:
Novice
2026-01-09 14:19:27 +08:00
294 changed files with 14621 additions and 3601 deletions

View File

@ -49,6 +49,7 @@ from .model import (
EndUser,
IconType,
InstalledApp,
LLMGenerationDetail,
Message,
MessageAgentThought,
MessageAnnotation,
@ -155,6 +156,7 @@ __all__ = [
"IconType",
"InstalledApp",
"InvitationCode",
"LLMGenerationDetail",
"LoadBalancingModelConfig",
"Message",
"MessageAgentThought",

View File

@ -1,3 +1,5 @@
from __future__ import annotations
import json
import re
import uuid
@ -5,7 +7,7 @@ from collections.abc import Mapping
from datetime import datetime
from decimal import Decimal
from enum import StrEnum, auto
from typing import TYPE_CHECKING, Any, Literal, Optional, cast
from typing import TYPE_CHECKING, Any, Literal, cast
from uuid import uuid4
import sqlalchemy as sa
@ -31,6 +33,8 @@ from .provider_ids import GenericProviderID
from .types import LongText, StringUUID
if TYPE_CHECKING:
from core.app.entities.llm_generation_entities import LLMGenerationDetailData
from .workflow import Workflow
@ -54,7 +58,7 @@ class AppMode(StrEnum):
RAG_PIPELINE = "rag-pipeline"
@classmethod
def value_of(cls, value: str) -> "AppMode":
def value_of(cls, value: str) -> AppMode:
"""
Get value of given mode.
@ -70,6 +74,7 @@ class AppMode(StrEnum):
class IconType(StrEnum):
IMAGE = auto()
EMOJI = auto()
LINK = auto()
class App(Base):
@ -81,7 +86,7 @@ class App(Base):
name: Mapped[str] = mapped_column(String(255))
description: Mapped[str] = mapped_column(LongText, default=sa.text("''"))
mode: Mapped[str] = mapped_column(String(255))
icon_type: Mapped[str | None] = mapped_column(String(255)) # image, emoji
icon_type: Mapped[str | None] = mapped_column(String(255)) # image, emoji, link
icon = mapped_column(String(255))
icon_background: Mapped[str | None] = mapped_column(String(255))
app_model_config_id = mapped_column(StringUUID, nullable=True)
@ -120,19 +125,19 @@ class App(Base):
return ""
@property
def site(self) -> Optional["Site"]:
def site(self) -> Site | None:
site = db.session.query(Site).where(Site.app_id == self.id).first()
return site
@property
def app_model_config(self) -> Optional["AppModelConfig"]:
def app_model_config(self) -> AppModelConfig | None:
if self.app_model_config_id:
return db.session.query(AppModelConfig).where(AppModelConfig.id == self.app_model_config_id).first()
return None
@property
def workflow(self) -> Optional["Workflow"]:
def workflow(self) -> Workflow | None:
if self.workflow_id:
from .workflow import Workflow
@ -287,7 +292,7 @@ class App(Base):
return deleted_tools
@property
def tags(self) -> list["Tag"]:
def tags(self) -> list[Tag]:
tags = (
db.session.query(Tag)
.join(TagBinding, Tag.id == TagBinding.tag_id)
@ -1193,7 +1198,7 @@ class Message(Base):
return json.loads(self.message_metadata) if self.message_metadata else {}
@property
def agent_thoughts(self) -> list["MessageAgentThought"]:
def agent_thoughts(self) -> list[MessageAgentThought]:
return (
db.session.query(MessageAgentThought)
.where(MessageAgentThought.message_id == self.id)
@ -1201,6 +1206,18 @@ class Message(Base):
.all()
)
# FIXME (Novice) -- It's easy to cause N+1 query problem here.
@property
def generation_detail(self) -> dict[str, Any] | None:
"""
Get LLM generation detail for this message.
Returns the detail as a dictionary or None if not found.
"""
detail = db.session.query(LLMGenerationDetail).filter_by(message_id=self.id).first()
if detail:
return detail.to_dict()
return None
@property
def retriever_resources(self) -> Any:
return self.message_metadata_dict.get("retriever_resources") if self.message_metadata else []
@ -1306,7 +1323,7 @@ class Message(Base):
}
@classmethod
def from_dict(cls, data: dict[str, Any]) -> "Message":
def from_dict(cls, data: dict[str, Any]) -> Message:
return cls(
id=data["id"],
app_id=data["app_id"],
@ -1533,7 +1550,7 @@ class OperationLog(TypeBase):
tenant_id: Mapped[str] = mapped_column(StringUUID, nullable=False)
account_id: Mapped[str] = mapped_column(StringUUID, nullable=False)
action: Mapped[str] = mapped_column(String(255), nullable=False)
content: Mapped[Any] = mapped_column(sa.JSON)
content: Mapped[Any | None] = mapped_column(sa.JSON, nullable=True)
created_at: Mapped[datetime] = mapped_column(
sa.DateTime, nullable=False, server_default=func.current_timestamp(), init=False
)
@ -2070,3 +2087,87 @@ class TraceAppConfig(TypeBase):
"created_at": str(self.created_at) if self.created_at else None,
"updated_at": str(self.updated_at) if self.updated_at else None,
}
class LLMGenerationDetail(Base):
"""
Store LLM generation details including reasoning process and tool calls.
Association (choose one):
- For apps with Message: use message_id (one-to-one)
- For Workflow: use workflow_run_id + node_id (one run may have multiple LLM nodes)
"""
__tablename__ = "llm_generation_details"
__table_args__ = (
sa.PrimaryKeyConstraint("id", name="llm_generation_detail_pkey"),
sa.Index("idx_llm_generation_detail_message", "message_id"),
sa.Index("idx_llm_generation_detail_workflow", "workflow_run_id", "node_id"),
sa.CheckConstraint(
"(message_id IS NOT NULL AND workflow_run_id IS NULL AND node_id IS NULL)"
" OR "
"(message_id IS NULL AND workflow_run_id IS NOT NULL AND node_id IS NOT NULL)",
name="ck_llm_generation_detail_assoc_mode",
),
)
id: Mapped[str] = mapped_column(StringUUID, default=lambda: str(uuid4()))
tenant_id: Mapped[str] = mapped_column(StringUUID, nullable=False)
app_id: Mapped[str] = mapped_column(StringUUID, nullable=False)
# Association fields (choose one)
message_id: Mapped[str | None] = mapped_column(StringUUID, nullable=True, unique=True)
workflow_run_id: Mapped[str | None] = mapped_column(StringUUID, nullable=True)
node_id: Mapped[str | None] = mapped_column(String(255), nullable=True)
# Core data as JSON strings
reasoning_content: Mapped[str | None] = mapped_column(LongText)
tool_calls: Mapped[str | None] = mapped_column(LongText)
sequence: Mapped[str | None] = mapped_column(LongText)
created_at: Mapped[datetime] = mapped_column(sa.DateTime, nullable=False, server_default=func.current_timestamp())
def to_domain_model(self) -> "LLMGenerationDetailData":
"""Convert to Pydantic domain model with proper validation."""
from core.app.entities.llm_generation_entities import LLMGenerationDetailData
return LLMGenerationDetailData(
reasoning_content=json.loads(self.reasoning_content) if self.reasoning_content else [],
tool_calls=json.loads(self.tool_calls) if self.tool_calls else [],
sequence=json.loads(self.sequence) if self.sequence else [],
)
def to_dict(self) -> dict[str, Any]:
"""Convert to dictionary for API response."""
return self.to_domain_model().to_response_dict()
@classmethod
def from_domain_model(
cls,
data: "LLMGenerationDetailData",
*,
tenant_id: str,
app_id: str,
message_id: str | None = None,
workflow_run_id: str | None = None,
node_id: str | None = None,
) -> "LLMGenerationDetail":
"""Create from Pydantic domain model."""
# Enforce association mode at object creation time as well.
message_mode = message_id is not None
workflow_mode = workflow_run_id is not None or node_id is not None
if message_mode and workflow_mode:
raise ValueError("LLMGenerationDetail cannot set both message_id and workflow_run_id/node_id.")
if not message_mode and not (workflow_run_id and node_id):
raise ValueError("LLMGenerationDetail requires either message_id or workflow_run_id+node_id.")
return cls(
tenant_id=tenant_id,
app_id=app_id,
message_id=message_id,
workflow_run_id=workflow_run_id,
node_id=node_id,
reasoning_content=json.dumps(data.reasoning_content) if data.reasoning_content else None,
tool_calls=json.dumps([tc.model_dump() for tc in data.tool_calls]) if data.tool_calls else None,
sequence=json.dumps([seg.model_dump() for seg in data.sequence]) if data.sequence else None,
)

View File

@ -1,3 +1,5 @@
from __future__ import annotations
from datetime import datetime
from enum import StrEnum, auto
from functools import cached_property
@ -19,7 +21,7 @@ class ProviderType(StrEnum):
SYSTEM = auto()
@staticmethod
def value_of(value: str) -> "ProviderType":
def value_of(value: str) -> ProviderType:
for member in ProviderType:
if member.value == value:
return member
@ -37,7 +39,7 @@ class ProviderQuotaType(StrEnum):
"""hosted trial quota"""
@staticmethod
def value_of(value: str) -> "ProviderQuotaType":
def value_of(value: str) -> ProviderQuotaType:
for member in ProviderQuotaType:
if member.value == value:
return member
@ -76,7 +78,7 @@ class Provider(TypeBase):
quota_type: Mapped[str | None] = mapped_column(String(40), nullable=True, server_default=text("''"), default="")
quota_limit: Mapped[int | None] = mapped_column(sa.BigInteger, nullable=True, default=None)
quota_used: Mapped[int] = mapped_column(sa.BigInteger, nullable=False, default=0)
quota_used: Mapped[int | None] = mapped_column(sa.BigInteger, nullable=True, default=0)
created_at: Mapped[datetime] = mapped_column(
DateTime, nullable=False, server_default=func.current_timestamp(), init=False

View File

@ -1,3 +1,5 @@
from __future__ import annotations
import json
from datetime import datetime
from decimal import Decimal
@ -167,11 +169,11 @@ class ApiToolProvider(TypeBase):
)
@property
def schema_type(self) -> "ApiProviderSchemaType":
def schema_type(self) -> ApiProviderSchemaType:
return ApiProviderSchemaType.value_of(self.schema_type_str)
@property
def tools(self) -> list["ApiToolBundle"]:
def tools(self) -> list[ApiToolBundle]:
return [ApiToolBundle.model_validate(tool) for tool in json.loads(self.tools_str)]
@property
@ -267,7 +269,7 @@ class WorkflowToolProvider(TypeBase):
return db.session.query(Tenant).where(Tenant.id == self.tenant_id).first()
@property
def parameter_configurations(self) -> list["WorkflowToolParameterConfiguration"]:
def parameter_configurations(self) -> list[WorkflowToolParameterConfiguration]:
return [
WorkflowToolParameterConfiguration.model_validate(config)
for config in json.loads(self.parameter_configuration)
@ -359,7 +361,7 @@ class MCPToolProvider(TypeBase):
except (json.JSONDecodeError, TypeError):
return []
def to_entity(self) -> "MCPProviderEntity":
def to_entity(self) -> MCPProviderEntity:
"""Convert to domain entity"""
from core.entities.mcp_provider import MCPProviderEntity
@ -533,5 +535,5 @@ class DeprecatedPublishedAppTool(TypeBase):
)
@property
def description_i18n(self) -> "I18nObject":
def description_i18n(self) -> I18nObject:
return I18nObject.model_validate(json.loads(self.description))

View File

@ -415,7 +415,7 @@ class AppTrigger(TypeBase):
node_id: Mapped[str | None] = mapped_column(String(64), nullable=False)
trigger_type: Mapped[str] = mapped_column(EnumText(AppTriggerType, length=50), nullable=False)
title: Mapped[str] = mapped_column(String(255), nullable=False)
provider_name: Mapped[str] = mapped_column(String(255), server_default="", default="") # why it is nullable?
provider_name: Mapped[str | None] = mapped_column(String(255), nullable=True, server_default="", default="")
status: Mapped[str] = mapped_column(
EnumText(AppTriggerStatus, length=50), nullable=False, default=AppTriggerStatus.ENABLED
)

View File

@ -1,9 +1,11 @@
from __future__ import annotations
import json
import logging
from collections.abc import Generator, Mapping, Sequence
from datetime import datetime
from enum import StrEnum
from typing import TYPE_CHECKING, Any, Optional, Union, cast
from typing import TYPE_CHECKING, Any, Union, cast
from uuid import uuid4
import sqlalchemy as sa
@ -57,6 +59,37 @@ from .types import EnumText, LongText, StringUUID
logger = logging.getLogger(__name__)
def is_generation_outputs(outputs: Mapping[str, Any]) -> bool:
if not outputs:
return False
allowed_sequence_types = {"reasoning", "content", "tool_call"}
def valid_sequence_item(item: Mapping[str, Any]) -> bool:
return isinstance(item, Mapping) and item.get("type") in allowed_sequence_types
def valid_value(value: Any) -> bool:
if not isinstance(value, Mapping):
return False
content = value.get("content")
reasoning_content = value.get("reasoning_content")
tool_calls = value.get("tool_calls")
sequence = value.get("sequence")
return (
isinstance(content, str)
and isinstance(reasoning_content, list)
and all(isinstance(item, str) for item in reasoning_content)
and isinstance(tool_calls, list)
and all(isinstance(item, Mapping) for item in tool_calls)
and isinstance(sequence, list)
and all(valid_sequence_item(item) for item in sequence)
)
return all(valid_value(value) for value in outputs.values())
class WorkflowType(StrEnum):
"""
Workflow Type Enum
@ -67,7 +100,7 @@ class WorkflowType(StrEnum):
RAG_PIPELINE = "rag-pipeline"
@classmethod
def value_of(cls, value: str) -> "WorkflowType":
def value_of(cls, value: str) -> WorkflowType:
"""
Get value of given mode.
@ -80,7 +113,7 @@ class WorkflowType(StrEnum):
raise ValueError(f"invalid workflow type value {value}")
@classmethod
def from_app_mode(cls, app_mode: Union[str, "AppMode"]) -> "WorkflowType":
def from_app_mode(cls, app_mode: Union[str, AppMode]) -> WorkflowType:
"""
Get workflow type from app mode.
@ -181,7 +214,7 @@ class Workflow(Base): # bug
rag_pipeline_variables: list[dict],
marked_name: str = "",
marked_comment: str = "",
) -> "Workflow":
) -> Workflow:
workflow = Workflow()
workflow.id = str(uuid4())
workflow.tenant_id = tenant_id
@ -619,7 +652,7 @@ class WorkflowRun(Base):
finished_at: Mapped[datetime | None] = mapped_column(DateTime)
exceptions_count: Mapped[int] = mapped_column(sa.Integer, server_default=sa.text("0"), nullable=True)
pause: Mapped[Optional["WorkflowPause"]] = orm.relationship(
pause: Mapped[WorkflowPause | None] = orm.relationship(
"WorkflowPause",
primaryjoin="WorkflowRun.id == foreign(WorkflowPause.workflow_run_id)",
uselist=False,
@ -664,6 +697,10 @@ class WorkflowRun(Base):
def workflow(self):
return db.session.query(Workflow).where(Workflow.id == self.workflow_id).first()
@property
def outputs_as_generation(self):
return is_generation_outputs(self.outputs_dict)
def to_dict(self):
return {
"id": self.id,
@ -677,6 +714,7 @@ class WorkflowRun(Base):
"inputs": self.inputs_dict,
"status": self.status,
"outputs": self.outputs_dict,
"outputs_as_generation": self.outputs_as_generation,
"error": self.error,
"elapsed_time": self.elapsed_time,
"total_tokens": self.total_tokens,
@ -689,7 +727,7 @@ class WorkflowRun(Base):
}
@classmethod
def from_dict(cls, data: dict[str, Any]) -> "WorkflowRun":
def from_dict(cls, data: dict[str, Any]) -> WorkflowRun:
return cls(
id=data.get("id"),
tenant_id=data.get("tenant_id"),
@ -841,7 +879,7 @@ class WorkflowNodeExecutionModel(Base): # This model is expected to have `offlo
created_by: Mapped[str] = mapped_column(StringUUID)
finished_at: Mapped[datetime | None] = mapped_column(DateTime)
offload_data: Mapped[list["WorkflowNodeExecutionOffload"]] = orm.relationship(
offload_data: Mapped[list[WorkflowNodeExecutionOffload]] = orm.relationship(
"WorkflowNodeExecutionOffload",
primaryjoin="WorkflowNodeExecutionModel.id == foreign(WorkflowNodeExecutionOffload.node_execution_id)",
uselist=True,
@ -851,13 +889,13 @@ class WorkflowNodeExecutionModel(Base): # This model is expected to have `offlo
@staticmethod
def preload_offload_data(
query: Select[tuple["WorkflowNodeExecutionModel"]] | orm.Query["WorkflowNodeExecutionModel"],
query: Select[tuple[WorkflowNodeExecutionModel]] | orm.Query[WorkflowNodeExecutionModel],
):
return query.options(orm.selectinload(WorkflowNodeExecutionModel.offload_data))
@staticmethod
def preload_offload_data_and_files(
query: Select[tuple["WorkflowNodeExecutionModel"]] | orm.Query["WorkflowNodeExecutionModel"],
query: Select[tuple[WorkflowNodeExecutionModel]] | orm.Query[WorkflowNodeExecutionModel],
):
return query.options(
orm.selectinload(WorkflowNodeExecutionModel.offload_data).options(
@ -932,7 +970,7 @@ class WorkflowNodeExecutionModel(Base): # This model is expected to have `offlo
)
return extras
def _get_offload_by_type(self, type_: ExecutionOffLoadType) -> Optional["WorkflowNodeExecutionOffload"]:
def _get_offload_by_type(self, type_: ExecutionOffLoadType) -> WorkflowNodeExecutionOffload | None:
return next(iter([i for i in self.offload_data if i.type_ == type_]), None)
@property
@ -1046,7 +1084,7 @@ class WorkflowNodeExecutionOffload(Base):
back_populates="offload_data",
)
file: Mapped[Optional["UploadFile"]] = orm.relationship(
file: Mapped[UploadFile | None] = orm.relationship(
foreign_keys=[file_id],
lazy="raise",
uselist=False,
@ -1064,7 +1102,7 @@ class WorkflowAppLogCreatedFrom(StrEnum):
INSTALLED_APP = "installed-app"
@classmethod
def value_of(cls, value: str) -> "WorkflowAppLogCreatedFrom":
def value_of(cls, value: str) -> WorkflowAppLogCreatedFrom:
"""
Get value of given mode.
@ -1181,7 +1219,7 @@ class ConversationVariable(TypeBase):
)
@classmethod
def from_variable(cls, *, app_id: str, conversation_id: str, variable: Variable) -> "ConversationVariable":
def from_variable(cls, *, app_id: str, conversation_id: str, variable: Variable) -> ConversationVariable:
obj = cls(
id=variable.id,
app_id=app_id,
@ -1334,7 +1372,7 @@ class WorkflowDraftVariable(Base):
)
# Relationship to WorkflowDraftVariableFile
variable_file: Mapped[Optional["WorkflowDraftVariableFile"]] = orm.relationship(
variable_file: Mapped[WorkflowDraftVariableFile | None] = orm.relationship(
foreign_keys=[file_id],
lazy="raise",
uselist=False,
@ -1504,8 +1542,9 @@ class WorkflowDraftVariable(Base):
node_execution_id: str | None,
description: str = "",
file_id: str | None = None,
) -> "WorkflowDraftVariable":
) -> WorkflowDraftVariable:
variable = WorkflowDraftVariable()
variable.id = str(uuid4())
variable.created_at = naive_utc_now()
variable.updated_at = naive_utc_now()
variable.description = description
@ -1526,7 +1565,7 @@ class WorkflowDraftVariable(Base):
name: str,
value: Segment,
description: str = "",
) -> "WorkflowDraftVariable":
) -> WorkflowDraftVariable:
variable = cls._new(
app_id=app_id,
node_id=CONVERSATION_VARIABLE_NODE_ID,
@ -1547,7 +1586,7 @@ class WorkflowDraftVariable(Base):
value: Segment,
node_execution_id: str,
editable: bool = False,
) -> "WorkflowDraftVariable":
) -> WorkflowDraftVariable:
variable = cls._new(
app_id=app_id,
node_id=SYSTEM_VARIABLE_NODE_ID,
@ -1570,7 +1609,7 @@ class WorkflowDraftVariable(Base):
visible: bool = True,
editable: bool = True,
file_id: str | None = None,
) -> "WorkflowDraftVariable":
) -> WorkflowDraftVariable:
variable = cls._new(
app_id=app_id,
node_id=node_id,
@ -1666,7 +1705,7 @@ class WorkflowDraftVariableFile(Base):
)
# Relationship to UploadFile
upload_file: Mapped["UploadFile"] = orm.relationship(
upload_file: Mapped[UploadFile] = orm.relationship(
foreign_keys=[upload_file_id],
lazy="raise",
uselist=False,
@ -1733,7 +1772,7 @@ class WorkflowPause(DefaultFieldsMixin, Base):
state_object_key: Mapped[str] = mapped_column(String(length=255), nullable=False)
# Relationship to WorkflowRun
workflow_run: Mapped["WorkflowRun"] = orm.relationship(
workflow_run: Mapped[WorkflowRun] = orm.relationship(
foreign_keys=[workflow_run_id],
# require explicit preloading.
lazy="raise",
@ -1789,7 +1828,7 @@ class WorkflowPauseReason(DefaultFieldsMixin, Base):
)
@classmethod
def from_entity(cls, pause_reason: PauseReason) -> "WorkflowPauseReason":
def from_entity(cls, pause_reason: PauseReason) -> WorkflowPauseReason:
if isinstance(pause_reason, HumanInputRequired):
return cls(
type_=PauseReasonType.HUMAN_INPUT_REQUIRED, form_id=pause_reason.form_id, node_id=pause_reason.node_id