mirror of
https://github.com/langgenius/dify.git
synced 2026-05-31 06:06:20 +08:00
Compare commits
15 Commits
deploy/ent
...
feat/agent
| Author | SHA1 | Date | |
|---|---|---|---|
| 90d892c386 | |||
| 9508df254b | |||
| c3974aa822 | |||
| 3d2d456a6c | |||
| 732f81a419 | |||
| 43b744a33f | |||
| 3ac5c4addc | |||
| 69f727d3a5 | |||
| 5a957fd36b | |||
| a1ef47710d | |||
| 246c1d74a4 | |||
| 1fc9a7802c | |||
| 71512ad2be | |||
| d067d84811 | |||
| 2cc567c6a3 |
@ -34,6 +34,7 @@ from clients.agent_backend.request_builder import (
|
||||
DIFY_PLUGIN_TOOLS_LAYER_ID,
|
||||
WORKFLOW_NODE_JOB_PROMPT_LAYER_ID,
|
||||
WORKFLOW_USER_PROMPT_LAYER_ID,
|
||||
AgentBackendAgentAppRunInput,
|
||||
AgentBackendModelConfig,
|
||||
AgentBackendOutputConfig,
|
||||
AgentBackendRunRequestBuilder,
|
||||
@ -49,6 +50,7 @@ __all__ = [
|
||||
"DIFY_PLUGIN_TOOLS_LAYER_ID",
|
||||
"WORKFLOW_NODE_JOB_PROMPT_LAYER_ID",
|
||||
"WORKFLOW_USER_PROMPT_LAYER_ID",
|
||||
"AgentBackendAgentAppRunInput",
|
||||
"AgentBackendError",
|
||||
"AgentBackendHTTPError",
|
||||
"AgentBackendInternalEvent",
|
||||
|
||||
@ -45,6 +45,7 @@ from pydantic import BaseModel, ConfigDict, Field, JsonValue, field_validator
|
||||
AGENT_SOUL_PROMPT_LAYER_ID = "agent_soul_prompt"
|
||||
WORKFLOW_NODE_JOB_PROMPT_LAYER_ID = "workflow_node_job_prompt"
|
||||
WORKFLOW_USER_PROMPT_LAYER_ID = "workflow_user_prompt"
|
||||
AGENT_APP_USER_PROMPT_LAYER_ID = "agent_app_user_prompt"
|
||||
DIFY_EXECUTION_CONTEXT_LAYER_ID = "execution_context"
|
||||
DIFY_PLUGIN_TOOLS_LAYER_ID = "tools"
|
||||
|
||||
@ -181,9 +182,138 @@ class AgentBackendWorkflowNodeRunInput(BaseModel):
|
||||
return value
|
||||
|
||||
|
||||
class AgentBackendAgentAppRunInput(BaseModel):
|
||||
"""Inputs to build one Agent App conversation-turn run request.
|
||||
|
||||
Unlike the workflow-node input there is no workflow-node-job prompt and no
|
||||
previous-node context: the user prompt is the chat message, and multi-turn
|
||||
continuity comes from ``session_snapshot`` + the history layer keyed by the
|
||||
conversation.
|
||||
"""
|
||||
|
||||
model: AgentBackendModelConfig
|
||||
execution_context: DifyExecutionContextLayerConfig
|
||||
user_prompt: str
|
||||
agent_soul_prompt: str | None = None
|
||||
purpose: RunPurpose = "agent_app"
|
||||
idempotency_key: str | None = None
|
||||
output: AgentBackendOutputConfig | None = None
|
||||
tools: DifyPluginToolsLayerConfig | None = None
|
||||
session_snapshot: CompositorSessionSnapshot | None = None
|
||||
include_history: bool = True
|
||||
suspend_on_exit: bool = True
|
||||
metadata: dict[str, JsonValue] = Field(default_factory=dict)
|
||||
|
||||
model_config: ClassVar[ConfigDict] = ConfigDict(extra="forbid", arbitrary_types_allowed=True)
|
||||
|
||||
@field_validator("user_prompt")
|
||||
@classmethod
|
||||
def _reject_blank_prompt(cls, value: str) -> str:
|
||||
if not value.strip():
|
||||
raise ValueError("prompt must not be blank")
|
||||
return value
|
||||
|
||||
|
||||
class AgentBackendRunRequestBuilder:
|
||||
"""Converts API product state into the public ``dify-agent`` run protocol."""
|
||||
|
||||
def build_for_agent_app(self, run_input: AgentBackendAgentAppRunInput) -> CreateRunRequest:
|
||||
"""Build an Agent App conversation-turn run request.
|
||||
|
||||
Layer graph: optional Agent Soul system prompt → user prompt →
|
||||
execution context → optional history (multi-turn) → LLM → optional
|
||||
plugin tools → optional structured output. Mirrors the workflow-node
|
||||
layer ordering minus the workflow-job / previous-node prompt.
|
||||
"""
|
||||
layers: list[RunLayerSpec] = []
|
||||
if run_input.agent_soul_prompt:
|
||||
layers.append(
|
||||
RunLayerSpec(
|
||||
name=AGENT_SOUL_PROMPT_LAYER_ID,
|
||||
type=PLAIN_PROMPT_LAYER_TYPE_ID,
|
||||
metadata={**run_input.metadata, "origin": "agent_soul"},
|
||||
config=PromptLayerConfig(prefix=run_input.agent_soul_prompt),
|
||||
)
|
||||
)
|
||||
|
||||
layers.extend(
|
||||
[
|
||||
RunLayerSpec(
|
||||
name=AGENT_APP_USER_PROMPT_LAYER_ID,
|
||||
type=PLAIN_PROMPT_LAYER_TYPE_ID,
|
||||
metadata={**run_input.metadata, "origin": "agent_app_user_prompt"},
|
||||
config=PromptLayerConfig(user=run_input.user_prompt),
|
||||
),
|
||||
RunLayerSpec(
|
||||
name=DIFY_EXECUTION_CONTEXT_LAYER_ID,
|
||||
type=DIFY_EXECUTION_CONTEXT_LAYER_TYPE_ID,
|
||||
metadata=run_input.metadata,
|
||||
config=run_input.execution_context,
|
||||
),
|
||||
]
|
||||
)
|
||||
|
||||
if run_input.include_history:
|
||||
layers.append(
|
||||
RunLayerSpec(
|
||||
name=DIFY_AGENT_HISTORY_LAYER_ID,
|
||||
type=PYDANTIC_AI_HISTORY_LAYER_TYPE_ID,
|
||||
metadata={**run_input.metadata, "origin": "agent_session_history"},
|
||||
)
|
||||
)
|
||||
|
||||
layers.append(
|
||||
RunLayerSpec(
|
||||
name=DIFY_AGENT_MODEL_LAYER_ID,
|
||||
type=DIFY_PLUGIN_LLM_LAYER_TYPE_ID,
|
||||
deps={"execution_context": DIFY_EXECUTION_CONTEXT_LAYER_ID},
|
||||
metadata=run_input.metadata,
|
||||
config=DifyPluginLLMLayerConfig(
|
||||
plugin_id=run_input.model.plugin_id,
|
||||
model_provider=run_input.model.model_provider,
|
||||
model=run_input.model.model,
|
||||
credentials=run_input.model.credentials,
|
||||
model_settings=run_input.model.model_settings or None,
|
||||
),
|
||||
)
|
||||
)
|
||||
|
||||
if run_input.tools is not None and run_input.tools.tools:
|
||||
layers.append(
|
||||
RunLayerSpec(
|
||||
name=DIFY_PLUGIN_TOOLS_LAYER_ID,
|
||||
type=DIFY_PLUGIN_TOOLS_LAYER_TYPE_ID,
|
||||
deps={"execution_context": DIFY_EXECUTION_CONTEXT_LAYER_ID},
|
||||
metadata=run_input.metadata,
|
||||
config=run_input.tools,
|
||||
)
|
||||
)
|
||||
|
||||
if run_input.output is not None:
|
||||
layers.append(
|
||||
RunLayerSpec(
|
||||
name=DIFY_AGENT_OUTPUT_LAYER_ID,
|
||||
type=DIFY_OUTPUT_LAYER_TYPE_ID,
|
||||
metadata=run_input.metadata,
|
||||
config=DifyOutputLayerConfig(
|
||||
json_schema=run_input.output.json_schema,
|
||||
description=run_input.output.description,
|
||||
strict=run_input.output.strict,
|
||||
),
|
||||
)
|
||||
)
|
||||
|
||||
return CreateRunRequest(
|
||||
composition=RunComposition(layers=layers),
|
||||
purpose=run_input.purpose,
|
||||
idempotency_key=run_input.idempotency_key,
|
||||
metadata=run_input.metadata,
|
||||
session_snapshot=run_input.session_snapshot,
|
||||
on_exit=LayerExitSignals(
|
||||
default=ExitIntent.SUSPEND if run_input.suspend_on_exit else ExitIntent.DELETE,
|
||||
),
|
||||
)
|
||||
|
||||
def build_cleanup_request(
|
||||
self,
|
||||
*,
|
||||
|
||||
@ -81,4 +81,15 @@ default_app_templates: Mapping[AppMode, Mapping] = {
|
||||
},
|
||||
},
|
||||
},
|
||||
# agent default mode (new Agent App type). The runtime model / prompt / tools
|
||||
# come from the bound Agent Soul snapshot, so no model_config is seeded in the
|
||||
# template; create_app still creates a model-less app_model_config row to hold
|
||||
# app-level presentation features (opener, follow-up, citations, ...).
|
||||
AppMode.AGENT: {
|
||||
"app": {
|
||||
"mode": AppMode.AGENT,
|
||||
"enable_site": True,
|
||||
"enable_api": True,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
@ -1,9 +1,17 @@
|
||||
from flask_restx import Resource
|
||||
|
||||
from controllers.common.schema import register_schema_models
|
||||
from controllers.common.schema import register_response_schema_models, register_schema_models
|
||||
from controllers.console import console_ns
|
||||
from controllers.console.app.wraps import get_app_model
|
||||
from controllers.console.wraps import account_initialization_required, edit_permission_required, setup_required
|
||||
from fields.agent_fields import (
|
||||
AgentAppComposerResponse,
|
||||
AgentComposerCandidatesResponse,
|
||||
AgentComposerImpactResponse,
|
||||
AgentComposerValidateResponse,
|
||||
WorkflowAgentComposerResponse,
|
||||
)
|
||||
from libs.helper import dump_response
|
||||
from libs.login import current_account_with_tenant, login_required
|
||||
from models.model import App, AppMode
|
||||
from services.agent.composer_service import AgentComposerService
|
||||
@ -11,23 +19,40 @@ from services.agent.composer_validator import ComposerConfigValidator
|
||||
from services.entities.agent_entities import ComposerSavePayload
|
||||
|
||||
register_schema_models(console_ns, ComposerSavePayload)
|
||||
register_response_schema_models(
|
||||
console_ns,
|
||||
AgentAppComposerResponse,
|
||||
AgentComposerCandidatesResponse,
|
||||
AgentComposerImpactResponse,
|
||||
AgentComposerValidateResponse,
|
||||
WorkflowAgentComposerResponse,
|
||||
)
|
||||
|
||||
|
||||
@console_ns.route("/apps/<uuid:app_id>/workflows/draft/nodes/<string:node_id>/agent-composer")
|
||||
class WorkflowAgentComposerApi(Resource):
|
||||
@console_ns.response(
|
||||
200, "Workflow agent composer state", console_ns.models[WorkflowAgentComposerResponse.__name__]
|
||||
)
|
||||
@setup_required
|
||||
@login_required
|
||||
@account_initialization_required
|
||||
@get_app_model(mode=[AppMode.WORKFLOW, AppMode.ADVANCED_CHAT])
|
||||
def get(self, app_model: App, node_id: str):
|
||||
_, tenant_id = current_account_with_tenant()
|
||||
return AgentComposerService.load_workflow_composer(
|
||||
tenant_id=tenant_id,
|
||||
app_id=app_model.id,
|
||||
node_id=node_id,
|
||||
return dump_response(
|
||||
WorkflowAgentComposerResponse,
|
||||
AgentComposerService.load_workflow_composer(
|
||||
tenant_id=tenant_id,
|
||||
app_id=app_model.id,
|
||||
node_id=node_id,
|
||||
),
|
||||
)
|
||||
|
||||
@console_ns.expect(console_ns.models[ComposerSavePayload.__name__])
|
||||
@console_ns.response(
|
||||
200, "Workflow agent composer saved", console_ns.models[WorkflowAgentComposerResponse.__name__]
|
||||
)
|
||||
@setup_required
|
||||
@login_required
|
||||
@account_initialization_required
|
||||
@ -36,18 +61,24 @@ class WorkflowAgentComposerApi(Resource):
|
||||
def put(self, app_model: App, node_id: str):
|
||||
account, tenant_id = current_account_with_tenant()
|
||||
payload = ComposerSavePayload.model_validate(console_ns.payload or {})
|
||||
return AgentComposerService.save_workflow_composer(
|
||||
tenant_id=tenant_id,
|
||||
app_id=app_model.id,
|
||||
node_id=node_id,
|
||||
account_id=account.id,
|
||||
payload=payload,
|
||||
return dump_response(
|
||||
WorkflowAgentComposerResponse,
|
||||
AgentComposerService.save_workflow_composer(
|
||||
tenant_id=tenant_id,
|
||||
app_id=app_model.id,
|
||||
node_id=node_id,
|
||||
account_id=account.id,
|
||||
payload=payload,
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
@console_ns.route("/apps/<uuid:app_id>/workflows/draft/nodes/<string:node_id>/agent-composer/validate")
|
||||
class WorkflowAgentComposerValidateApi(Resource):
|
||||
@console_ns.expect(console_ns.models[ComposerSavePayload.__name__])
|
||||
@console_ns.response(
|
||||
200, "Workflow agent composer validation result", console_ns.models[AgentComposerValidateResponse.__name__]
|
||||
)
|
||||
@setup_required
|
||||
@login_required
|
||||
@account_initialization_required
|
||||
@ -55,21 +86,29 @@ class WorkflowAgentComposerValidateApi(Resource):
|
||||
def post(self, app_model: App, node_id: str):
|
||||
payload = ComposerSavePayload.model_validate(console_ns.payload or {})
|
||||
ComposerConfigValidator.validate_save_payload(payload)
|
||||
return {"result": "success", "errors": []}
|
||||
return dump_response(AgentComposerValidateResponse, {"result": "success", "errors": []})
|
||||
|
||||
|
||||
@console_ns.route("/apps/<uuid:app_id>/workflows/draft/nodes/<string:node_id>/agent-composer/candidates")
|
||||
class WorkflowAgentComposerCandidatesApi(Resource):
|
||||
@console_ns.response(
|
||||
200, "Workflow agent composer candidates", console_ns.models[AgentComposerCandidatesResponse.__name__]
|
||||
)
|
||||
@setup_required
|
||||
@login_required
|
||||
@account_initialization_required
|
||||
@get_app_model(mode=[AppMode.WORKFLOW, AppMode.ADVANCED_CHAT])
|
||||
def get(self, app_model: App, node_id: str):
|
||||
return AgentComposerService.get_workflow_candidates(app_id=app_model.id)
|
||||
return dump_response(
|
||||
AgentComposerCandidatesResponse,
|
||||
AgentComposerService.get_workflow_candidates(app_id=app_model.id),
|
||||
)
|
||||
|
||||
|
||||
@console_ns.route("/apps/<uuid:app_id>/workflows/draft/nodes/<string:node_id>/agent-composer/impact")
|
||||
class WorkflowAgentComposerImpactApi(Resource):
|
||||
@console_ns.expect(console_ns.models[ComposerSavePayload.__name__])
|
||||
@console_ns.response(200, "Workflow agent composer impact", console_ns.models[AgentComposerImpactResponse.__name__])
|
||||
@setup_required
|
||||
@login_required
|
||||
@account_initialization_required
|
||||
@ -79,13 +118,21 @@ class WorkflowAgentComposerImpactApi(Resource):
|
||||
payload = ComposerSavePayload.model_validate(console_ns.payload or {})
|
||||
current_snapshot_id = payload.binding.current_snapshot_id if payload.binding else None
|
||||
if not current_snapshot_id:
|
||||
return {"current_snapshot_id": None, "workflow_node_count": 0, "bindings": []}
|
||||
return AgentComposerService.calculate_impact(tenant_id=tenant_id, current_snapshot_id=current_snapshot_id)
|
||||
return dump_response(
|
||||
AgentComposerImpactResponse, {"current_snapshot_id": None, "workflow_node_count": 0, "bindings": []}
|
||||
)
|
||||
return dump_response(
|
||||
AgentComposerImpactResponse,
|
||||
AgentComposerService.calculate_impact(tenant_id=tenant_id, current_snapshot_id=current_snapshot_id),
|
||||
)
|
||||
|
||||
|
||||
@console_ns.route("/apps/<uuid:app_id>/workflows/draft/nodes/<string:node_id>/agent-composer/save-to-roster")
|
||||
class WorkflowAgentComposerSaveToRosterApi(Resource):
|
||||
@console_ns.expect(console_ns.models[ComposerSavePayload.__name__])
|
||||
@console_ns.response(
|
||||
200, "Workflow agent composer saved to roster", console_ns.models[WorkflowAgentComposerResponse.__name__]
|
||||
)
|
||||
@setup_required
|
||||
@login_required
|
||||
@account_initialization_required
|
||||
@ -94,26 +141,34 @@ class WorkflowAgentComposerSaveToRosterApi(Resource):
|
||||
def post(self, app_model: App, node_id: str):
|
||||
account, tenant_id = current_account_with_tenant()
|
||||
payload = ComposerSavePayload.model_validate(console_ns.payload or {})
|
||||
return AgentComposerService.save_workflow_composer(
|
||||
tenant_id=tenant_id,
|
||||
app_id=app_model.id,
|
||||
node_id=node_id,
|
||||
account_id=account.id,
|
||||
payload=payload,
|
||||
return dump_response(
|
||||
WorkflowAgentComposerResponse,
|
||||
AgentComposerService.save_workflow_composer(
|
||||
tenant_id=tenant_id,
|
||||
app_id=app_model.id,
|
||||
node_id=node_id,
|
||||
account_id=account.id,
|
||||
payload=payload,
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
@console_ns.route("/apps/<uuid:app_id>/agent-composer")
|
||||
class AgentAppComposerApi(Resource):
|
||||
@console_ns.response(200, "Agent app composer state", console_ns.models[AgentAppComposerResponse.__name__])
|
||||
@setup_required
|
||||
@login_required
|
||||
@account_initialization_required
|
||||
@get_app_model()
|
||||
def get(self, app_model: App):
|
||||
_, tenant_id = current_account_with_tenant()
|
||||
return AgentComposerService.load_agent_app_composer(tenant_id=tenant_id, app_id=app_model.id)
|
||||
return dump_response(
|
||||
AgentAppComposerResponse,
|
||||
AgentComposerService.load_agent_app_composer(tenant_id=tenant_id, app_id=app_model.id),
|
||||
)
|
||||
|
||||
@console_ns.expect(console_ns.models[ComposerSavePayload.__name__])
|
||||
@console_ns.response(200, "Agent app composer saved", console_ns.models[AgentAppComposerResponse.__name__])
|
||||
@setup_required
|
||||
@login_required
|
||||
@account_initialization_required
|
||||
@ -122,17 +177,23 @@ class AgentAppComposerApi(Resource):
|
||||
def put(self, app_model: App):
|
||||
account, tenant_id = current_account_with_tenant()
|
||||
payload = ComposerSavePayload.model_validate(console_ns.payload or {})
|
||||
return AgentComposerService.save_agent_app_composer(
|
||||
tenant_id=tenant_id,
|
||||
app_id=app_model.id,
|
||||
account_id=account.id,
|
||||
payload=payload,
|
||||
return dump_response(
|
||||
AgentAppComposerResponse,
|
||||
AgentComposerService.save_agent_app_composer(
|
||||
tenant_id=tenant_id,
|
||||
app_id=app_model.id,
|
||||
account_id=account.id,
|
||||
payload=payload,
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
@console_ns.route("/apps/<uuid:app_id>/agent-composer/validate")
|
||||
class AgentAppComposerValidateApi(Resource):
|
||||
@console_ns.expect(console_ns.models[ComposerSavePayload.__name__])
|
||||
@console_ns.response(
|
||||
200, "Agent app composer validation result", console_ns.models[AgentComposerValidateResponse.__name__]
|
||||
)
|
||||
@setup_required
|
||||
@login_required
|
||||
@account_initialization_required
|
||||
@ -140,14 +201,20 @@ class AgentAppComposerValidateApi(Resource):
|
||||
def post(self, app_model: App):
|
||||
payload = ComposerSavePayload.model_validate(console_ns.payload or {})
|
||||
ComposerConfigValidator.validate_save_payload(payload)
|
||||
return {"result": "success", "errors": []}
|
||||
return dump_response(AgentComposerValidateResponse, {"result": "success", "errors": []})
|
||||
|
||||
|
||||
@console_ns.route("/apps/<uuid:app_id>/agent-composer/candidates")
|
||||
class AgentAppComposerCandidatesApi(Resource):
|
||||
@console_ns.response(
|
||||
200, "Agent app composer candidates", console_ns.models[AgentComposerCandidatesResponse.__name__]
|
||||
)
|
||||
@setup_required
|
||||
@login_required
|
||||
@account_initialization_required
|
||||
@get_app_model()
|
||||
def get(self, app_model: App):
|
||||
return AgentComposerService.get_agent_app_candidates(app_id=app_model.id)
|
||||
return dump_response(
|
||||
AgentComposerCandidatesResponse,
|
||||
AgentComposerService.get_agent_app_candidates(app_id=app_model.id),
|
||||
)
|
||||
|
||||
@ -4,10 +4,18 @@ from flask import request
|
||||
from flask_restx import Resource
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from controllers.common.schema import register_schema_models
|
||||
from controllers.common.schema import query_params_from_model, register_response_schema_models, register_schema_models
|
||||
from controllers.console import console_ns
|
||||
from controllers.console.wraps import account_initialization_required, edit_permission_required, setup_required
|
||||
from extensions.ext_database import db
|
||||
from fields.agent_fields import (
|
||||
AgentConfigSnapshotDetailResponse,
|
||||
AgentConfigSnapshotListResponse,
|
||||
AgentInviteOptionsResponse,
|
||||
AgentRosterListResponse,
|
||||
AgentRosterResponse,
|
||||
)
|
||||
from libs.helper import dump_response
|
||||
from libs.login import current_account_with_tenant, login_required
|
||||
from services.agent.roster_service import AgentRosterService
|
||||
from services.entities.agent_entities import RosterAgentCreatePayload, RosterAgentUpdatePayload, RosterListQuery
|
||||
@ -29,6 +37,14 @@ register_schema_models(
|
||||
RosterAgentUpdatePayload,
|
||||
RosterListQuery,
|
||||
)
|
||||
register_response_schema_models(
|
||||
console_ns,
|
||||
AgentConfigSnapshotDetailResponse,
|
||||
AgentConfigSnapshotListResponse,
|
||||
AgentInviteOptionsResponse,
|
||||
AgentRosterListResponse,
|
||||
AgentRosterResponse,
|
||||
)
|
||||
|
||||
|
||||
def _agent_roster_service() -> AgentRosterService:
|
||||
@ -37,17 +53,23 @@ def _agent_roster_service() -> AgentRosterService:
|
||||
|
||||
@console_ns.route("/agents")
|
||||
class AgentRosterListApi(Resource):
|
||||
@console_ns.doc(params=query_params_from_model(RosterListQuery))
|
||||
@console_ns.response(200, "Agent roster list", console_ns.models[AgentRosterListResponse.__name__])
|
||||
@setup_required
|
||||
@login_required
|
||||
@account_initialization_required
|
||||
def get(self):
|
||||
_, tenant_id = current_account_with_tenant()
|
||||
query = RosterListQuery.model_validate(request.args.to_dict(flat=True))
|
||||
return _agent_roster_service().list_roster_agents(
|
||||
tenant_id=tenant_id, page=query.page, limit=query.limit, keyword=query.keyword
|
||||
return dump_response(
|
||||
AgentRosterListResponse,
|
||||
_agent_roster_service().list_roster_agents(
|
||||
tenant_id=tenant_id, page=query.page, limit=query.limit, keyword=query.keyword
|
||||
),
|
||||
)
|
||||
|
||||
@console_ns.expect(console_ns.models[RosterAgentCreatePayload.__name__])
|
||||
@console_ns.response(201, "Agent created", console_ns.models[AgentRosterResponse.__name__])
|
||||
@setup_required
|
||||
@login_required
|
||||
@account_initialization_required
|
||||
@ -57,36 +79,49 @@ class AgentRosterListApi(Resource):
|
||||
payload = RosterAgentCreatePayload.model_validate(console_ns.payload or {})
|
||||
service = _agent_roster_service()
|
||||
agent = service.create_roster_agent(tenant_id=tenant_id, account_id=account.id, payload=payload)
|
||||
return service.get_roster_agent_detail(tenant_id=tenant_id, agent_id=agent.id), 201
|
||||
return dump_response(
|
||||
AgentRosterResponse,
|
||||
service.get_roster_agent_detail(tenant_id=tenant_id, agent_id=agent.id),
|
||||
), 201
|
||||
|
||||
|
||||
@console_ns.route("/agents/invite-options")
|
||||
class AgentInviteOptionsApi(Resource):
|
||||
@console_ns.doc(params=query_params_from_model(AgentInviteOptionsQuery))
|
||||
@console_ns.response(200, "Agent invite options", console_ns.models[AgentInviteOptionsResponse.__name__])
|
||||
@setup_required
|
||||
@login_required
|
||||
@account_initialization_required
|
||||
def get(self):
|
||||
_, tenant_id = current_account_with_tenant()
|
||||
query = AgentInviteOptionsQuery.model_validate(request.args.to_dict(flat=True))
|
||||
return _agent_roster_service().list_invite_options(
|
||||
tenant_id=tenant_id,
|
||||
page=query.page,
|
||||
limit=query.limit,
|
||||
keyword=query.keyword,
|
||||
app_id=query.app_id,
|
||||
return dump_response(
|
||||
AgentInviteOptionsResponse,
|
||||
_agent_roster_service().list_invite_options(
|
||||
tenant_id=tenant_id,
|
||||
page=query.page,
|
||||
limit=query.limit,
|
||||
keyword=query.keyword,
|
||||
app_id=query.app_id,
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
@console_ns.route("/agents/<uuid:agent_id>")
|
||||
class AgentRosterDetailApi(Resource):
|
||||
@console_ns.response(200, "Agent detail", console_ns.models[AgentRosterResponse.__name__])
|
||||
@setup_required
|
||||
@login_required
|
||||
@account_initialization_required
|
||||
def get(self, agent_id: UUID):
|
||||
_, tenant_id = current_account_with_tenant()
|
||||
return _agent_roster_service().get_roster_agent_detail(tenant_id=tenant_id, agent_id=str(agent_id))
|
||||
return dump_response(
|
||||
AgentRosterResponse,
|
||||
_agent_roster_service().get_roster_agent_detail(tenant_id=tenant_id, agent_id=str(agent_id)),
|
||||
)
|
||||
|
||||
@console_ns.expect(console_ns.models[RosterAgentUpdatePayload.__name__])
|
||||
@console_ns.response(200, "Agent updated", console_ns.models[AgentRosterResponse.__name__])
|
||||
@setup_required
|
||||
@login_required
|
||||
@account_initialization_required
|
||||
@ -94,10 +129,14 @@ class AgentRosterDetailApi(Resource):
|
||||
def patch(self, agent_id: UUID):
|
||||
account, tenant_id = current_account_with_tenant()
|
||||
payload = RosterAgentUpdatePayload.model_validate(console_ns.payload or {})
|
||||
return _agent_roster_service().update_roster_agent(
|
||||
tenant_id=tenant_id, agent_id=str(agent_id), account_id=account.id, payload=payload
|
||||
return dump_response(
|
||||
AgentRosterResponse,
|
||||
_agent_roster_service().update_roster_agent(
|
||||
tenant_id=tenant_id, agent_id=str(agent_id), account_id=account.id, payload=payload
|
||||
),
|
||||
)
|
||||
|
||||
@console_ns.response(204, "Agent archived")
|
||||
@setup_required
|
||||
@login_required
|
||||
@account_initialization_required
|
||||
@ -110,23 +149,31 @@ class AgentRosterDetailApi(Resource):
|
||||
|
||||
@console_ns.route("/agents/<uuid:agent_id>/versions")
|
||||
class AgentRosterVersionsApi(Resource):
|
||||
@console_ns.response(200, "Agent versions", console_ns.models[AgentConfigSnapshotListResponse.__name__])
|
||||
@setup_required
|
||||
@login_required
|
||||
@account_initialization_required
|
||||
def get(self, agent_id: UUID):
|
||||
_, tenant_id = current_account_with_tenant()
|
||||
return {"data": _agent_roster_service().list_agent_versions(tenant_id=tenant_id, agent_id=str(agent_id))}
|
||||
return dump_response(
|
||||
AgentConfigSnapshotListResponse,
|
||||
{"data": _agent_roster_service().list_agent_versions(tenant_id=tenant_id, agent_id=str(agent_id))},
|
||||
)
|
||||
|
||||
|
||||
@console_ns.route("/agents/<uuid:agent_id>/versions/<uuid:version_id>")
|
||||
class AgentRosterVersionDetailApi(Resource):
|
||||
@console_ns.response(200, "Agent version detail", console_ns.models[AgentConfigSnapshotDetailResponse.__name__])
|
||||
@setup_required
|
||||
@login_required
|
||||
@account_initialization_required
|
||||
def get(self, agent_id: UUID, version_id: UUID):
|
||||
_, tenant_id = current_account_with_tenant()
|
||||
return _agent_roster_service().get_agent_version_detail(
|
||||
tenant_id=tenant_id,
|
||||
agent_id=str(agent_id),
|
||||
version_id=str(version_id),
|
||||
return dump_response(
|
||||
AgentConfigSnapshotDetailResponse,
|
||||
_agent_roster_service().get_agent_version_detail(
|
||||
tenant_id=tenant_id,
|
||||
agent_id=str(agent_id),
|
||||
version_id=str(version_id),
|
||||
),
|
||||
)
|
||||
|
||||
@ -393,6 +393,8 @@ class AppDetailWithSite(AppDetail):
|
||||
max_active_requests: int | None = None
|
||||
deleted_tools: list[DeletedTool] = Field(default_factory=list)
|
||||
site: Site | None = None
|
||||
# For Agent App type: the roster Agent backing this app (None otherwise).
|
||||
bound_agent_id: str | None = None
|
||||
|
||||
@computed_field(return_type=str | None) # type: ignore
|
||||
@property
|
||||
|
||||
@ -43,7 +43,11 @@ logger = logging.getLogger(__name__)
|
||||
|
||||
class BaseMessagePayload(BaseModel):
|
||||
inputs: dict[str, Any]
|
||||
model_config_data: dict[str, Any] = Field(..., alias="model_config")
|
||||
# Agent Apps (AppMode.AGENT) derive their model + prompt from the bound Agent
|
||||
# Soul, so no override ``model_config`` is sent; chat / agent-chat / completion
|
||||
# debugging still pass it. Optional here, required in practice by those modes
|
||||
# downstream when their config is built from args.
|
||||
model_config_data: dict[str, Any] = Field(default_factory=dict, alias="model_config")
|
||||
files: list[Any] | None = Field(default=None, description="Uploaded files")
|
||||
response_mode: Literal["blocking", "streaming"] = Field(default="blocking", description="Response mode")
|
||||
retriever_from: str = Field(default="dev", description="Retriever source")
|
||||
@ -157,7 +161,7 @@ class ChatMessageApi(Resource):
|
||||
@setup_required
|
||||
@login_required
|
||||
@account_initialization_required
|
||||
@get_app_model(mode=[AppMode.CHAT, AppMode.AGENT_CHAT])
|
||||
@get_app_model(mode=[AppMode.CHAT, AppMode.AGENT_CHAT, AppMode.AGENT])
|
||||
@edit_permission_required
|
||||
def post(self, app_model: App):
|
||||
args_model = ChatMessagePayload.model_validate(console_ns.payload)
|
||||
|
||||
@ -205,7 +205,7 @@ class ChatConversationApi(Resource):
|
||||
@setup_required
|
||||
@login_required
|
||||
@account_initialization_required
|
||||
@get_app_model(mode=[AppMode.CHAT, AppMode.AGENT_CHAT, AppMode.ADVANCED_CHAT])
|
||||
@get_app_model(mode=[AppMode.CHAT, AppMode.AGENT_CHAT, AppMode.ADVANCED_CHAT, AppMode.AGENT])
|
||||
@edit_permission_required
|
||||
def get(self, app_model: App):
|
||||
current_user, _ = current_account_with_tenant()
|
||||
@ -316,7 +316,7 @@ class ChatConversationDetailApi(Resource):
|
||||
@setup_required
|
||||
@login_required
|
||||
@account_initialization_required
|
||||
@get_app_model(mode=[AppMode.CHAT, AppMode.AGENT_CHAT, AppMode.ADVANCED_CHAT])
|
||||
@get_app_model(mode=[AppMode.CHAT, AppMode.AGENT_CHAT, AppMode.ADVANCED_CHAT, AppMode.AGENT])
|
||||
@edit_permission_required
|
||||
def get(self, app_model: App, conversation_id: UUID):
|
||||
conversation_id_str = str(conversation_id)
|
||||
@ -332,7 +332,7 @@ class ChatConversationDetailApi(Resource):
|
||||
@console_ns.response(404, "Conversation not found")
|
||||
@setup_required
|
||||
@login_required
|
||||
@get_app_model(mode=[AppMode.CHAT, AppMode.AGENT_CHAT, AppMode.ADVANCED_CHAT])
|
||||
@get_app_model(mode=[AppMode.CHAT, AppMode.AGENT_CHAT, AppMode.ADVANCED_CHAT, AppMode.AGENT])
|
||||
@account_initialization_required
|
||||
@edit_permission_required
|
||||
def delete(self, app_model: App, conversation_id: UUID):
|
||||
|
||||
@ -178,7 +178,7 @@ class ChatMessageListApi(Resource):
|
||||
@login_required
|
||||
@account_initialization_required
|
||||
@setup_required
|
||||
@get_app_model(mode=[AppMode.CHAT, AppMode.AGENT_CHAT, AppMode.ADVANCED_CHAT])
|
||||
@get_app_model(mode=[AppMode.CHAT, AppMode.AGENT_CHAT, AppMode.ADVANCED_CHAT, AppMode.AGENT])
|
||||
@edit_permission_required
|
||||
def get(self, app_model: App):
|
||||
args = ChatMessagesQuery.model_validate(request.args.to_dict())
|
||||
|
||||
@ -293,7 +293,7 @@ class AverageSessionInteractionStatistic(Resource):
|
||||
@setup_required
|
||||
@login_required
|
||||
@account_initialization_required
|
||||
@get_app_model(mode=[AppMode.CHAT, AppMode.AGENT_CHAT, AppMode.ADVANCED_CHAT])
|
||||
@get_app_model(mode=[AppMode.CHAT, AppMode.AGENT_CHAT, AppMode.ADVANCED_CHAT, AppMode.AGENT])
|
||||
def get(self, app_model: App):
|
||||
account, _ = current_account_with_tenant()
|
||||
|
||||
|
||||
@ -1,11 +1,9 @@
|
||||
from typing import Any, cast
|
||||
|
||||
from controllers.common import fields
|
||||
from controllers.console import console_ns
|
||||
from controllers.console.app.error import AppUnavailableError
|
||||
from controllers.console.explore.wraps import InstalledAppResource
|
||||
from core.app.app_config.common.parameters_mapping import get_parameters_from_feature_dict
|
||||
from models.model import AppMode, InstalledApp
|
||||
from core.app.app_config.common.parameters_mapping import AppParametersUnavailableError, get_app_parameters
|
||||
from models.model import InstalledApp
|
||||
from services.app_service import AppService
|
||||
|
||||
|
||||
@ -20,23 +18,10 @@ class AppParameterApi(InstalledAppResource):
|
||||
if app_model is None:
|
||||
raise AppUnavailableError()
|
||||
|
||||
if app_model.mode in {AppMode.ADVANCED_CHAT, AppMode.WORKFLOW}:
|
||||
workflow = app_model.workflow
|
||||
if workflow is None:
|
||||
raise AppUnavailableError()
|
||||
|
||||
features_dict: dict[str, Any] = workflow.features_dict
|
||||
user_input_form = workflow.user_input_form(to_old_structure=True)
|
||||
else:
|
||||
app_model_config = app_model.app_model_config
|
||||
if app_model_config is None:
|
||||
raise AppUnavailableError()
|
||||
|
||||
features_dict = cast(dict[str, Any], app_model_config.to_dict())
|
||||
|
||||
user_input_form = features_dict.get("user_input_form", [])
|
||||
|
||||
parameters = get_parameters_from_feature_dict(features_dict=features_dict, user_input_form=user_input_form)
|
||||
try:
|
||||
parameters = get_app_parameters(app_model)
|
||||
except AppParametersUnavailableError:
|
||||
raise AppUnavailableError()
|
||||
return fields.Parameters.model_validate(parameters).model_dump(mode="json")
|
||||
|
||||
|
||||
|
||||
@ -1,5 +1,3 @@
|
||||
from typing import Any, cast
|
||||
|
||||
from flask_restx import Resource
|
||||
|
||||
from controllers.common.fields import Parameters
|
||||
@ -7,9 +5,9 @@ from controllers.common.schema import register_response_schema_models
|
||||
from controllers.service_api import service_api_ns
|
||||
from controllers.service_api.app.error import AppUnavailableError
|
||||
from controllers.service_api.wraps import validate_app_token
|
||||
from core.app.app_config.common.parameters_mapping import get_parameters_from_feature_dict
|
||||
from core.app.app_config.common.parameters_mapping import AppParametersUnavailableError, get_app_parameters
|
||||
from fields.base import ResponseModel
|
||||
from models.model import App, AppMode
|
||||
from models.model import App
|
||||
from services.app_service import AppService
|
||||
|
||||
|
||||
@ -43,23 +41,10 @@ class AppParameterApi(Resource):
|
||||
|
||||
Returns the input form parameters and configuration for the application.
|
||||
"""
|
||||
if app_model.mode in {AppMode.ADVANCED_CHAT, AppMode.WORKFLOW}:
|
||||
workflow = app_model.workflow
|
||||
if workflow is None:
|
||||
raise AppUnavailableError()
|
||||
|
||||
features_dict: dict[str, Any] = workflow.features_dict
|
||||
user_input_form = workflow.user_input_form(to_old_structure=True)
|
||||
else:
|
||||
app_model_config = app_model.app_model_config
|
||||
if app_model_config is None:
|
||||
raise AppUnavailableError()
|
||||
|
||||
features_dict = cast(dict[str, Any], app_model_config.to_dict())
|
||||
|
||||
user_input_form = features_dict.get("user_input_form", [])
|
||||
|
||||
parameters = get_parameters_from_feature_dict(features_dict=features_dict, user_input_form=user_input_form)
|
||||
try:
|
||||
parameters = get_app_parameters(app_model)
|
||||
except AppParametersUnavailableError:
|
||||
raise AppUnavailableError()
|
||||
return Parameters.model_validate(parameters).model_dump(mode="json")
|
||||
|
||||
|
||||
|
||||
@ -197,7 +197,7 @@ class ChatApi(Resource):
|
||||
Supports conversation management and both blocking and streaming response modes.
|
||||
"""
|
||||
app_mode = AppMode.value_of(app_model.mode)
|
||||
if app_mode not in {AppMode.CHAT, AppMode.AGENT_CHAT, AppMode.ADVANCED_CHAT}:
|
||||
if app_mode not in {AppMode.CHAT, AppMode.AGENT_CHAT, AppMode.ADVANCED_CHAT, AppMode.AGENT}:
|
||||
raise NotChatAppError()
|
||||
|
||||
payload = ChatRequestPayload.model_validate(service_api_ns.payload or {})
|
||||
@ -262,7 +262,7 @@ class ChatStopApi(Resource):
|
||||
def post(self, app_model: App, end_user: EndUser, task_id: str):
|
||||
"""Stop a running chat message generation."""
|
||||
app_mode = AppMode.value_of(app_model.mode)
|
||||
if app_mode not in {AppMode.CHAT, AppMode.AGENT_CHAT, AppMode.ADVANCED_CHAT}:
|
||||
if app_mode not in {AppMode.CHAT, AppMode.AGENT_CHAT, AppMode.ADVANCED_CHAT, AppMode.AGENT}:
|
||||
raise NotChatAppError()
|
||||
|
||||
AppTaskService.stop_task(
|
||||
|
||||
@ -1,5 +1,4 @@
|
||||
import logging
|
||||
from typing import Any, cast
|
||||
|
||||
from flask import request
|
||||
from flask_restx import Resource
|
||||
@ -9,10 +8,10 @@ from werkzeug.exceptions import Unauthorized
|
||||
from constants import HEADER_NAME_APP_CODE
|
||||
from controllers.common import fields
|
||||
from controllers.common.schema import register_response_schema_models, register_schema_models
|
||||
from core.app.app_config.common.parameters_mapping import get_parameters_from_feature_dict
|
||||
from core.app.app_config.common.parameters_mapping import AppParametersUnavailableError, get_app_parameters
|
||||
from libs.passport import PassportService
|
||||
from libs.token import extract_webapp_passport
|
||||
from models.model import App, AppMode, EndUser
|
||||
from models.model import App, EndUser
|
||||
from services.app_service import AppService
|
||||
from services.enterprise.enterprise_service import EnterpriseService
|
||||
from services.feature_service import FeatureService
|
||||
@ -58,23 +57,10 @@ class AppParameterApi(WebApiResource):
|
||||
)
|
||||
def get(self, app_model: App, end_user: EndUser):
|
||||
"""Retrieve app parameters."""
|
||||
if app_model.mode in {AppMode.ADVANCED_CHAT, AppMode.WORKFLOW}:
|
||||
workflow = app_model.workflow
|
||||
if workflow is None:
|
||||
raise AppUnavailableError()
|
||||
|
||||
features_dict: dict[str, Any] = workflow.features_dict
|
||||
user_input_form = workflow.user_input_form(to_old_structure=True)
|
||||
else:
|
||||
app_model_config = app_model.app_model_config
|
||||
if app_model_config is None:
|
||||
raise AppUnavailableError()
|
||||
|
||||
features_dict = cast(dict[str, Any], app_model_config.to_dict())
|
||||
|
||||
user_input_form = features_dict.get("user_input_form", [])
|
||||
|
||||
parameters = get_parameters_from_feature_dict(features_dict=features_dict, user_input_form=user_input_form)
|
||||
try:
|
||||
parameters = get_app_parameters(app_model)
|
||||
except AppParametersUnavailableError:
|
||||
raise AppUnavailableError()
|
||||
return fields.Parameters.model_validate(parameters).model_dump(mode="json")
|
||||
|
||||
|
||||
|
||||
@ -171,7 +171,7 @@ class ChatApi(WebApiResource):
|
||||
)
|
||||
def post(self, app_model: App, end_user: EndUser):
|
||||
app_mode = AppMode.value_of(app_model.mode)
|
||||
if app_mode not in {AppMode.CHAT, AppMode.AGENT_CHAT, AppMode.ADVANCED_CHAT}:
|
||||
if app_mode not in {AppMode.CHAT, AppMode.AGENT_CHAT, AppMode.ADVANCED_CHAT, AppMode.AGENT}:
|
||||
raise NotChatAppError()
|
||||
|
||||
payload = ChatMessagePayload.model_validate(web_ns.payload or {})
|
||||
@ -228,7 +228,7 @@ class ChatStopApi(WebApiResource):
|
||||
@web_ns.response(200, "Success", web_ns.models[SimpleResultResponse.__name__])
|
||||
def post(self, app_model: App, end_user: EndUser, task_id: str):
|
||||
app_mode = AppMode.value_of(app_model.mode)
|
||||
if app_mode not in {AppMode.CHAT, AppMode.AGENT_CHAT, AppMode.ADVANCED_CHAT}:
|
||||
if app_mode not in {AppMode.CHAT, AppMode.AGENT_CHAT, AppMode.ADVANCED_CHAT, AppMode.AGENT}:
|
||||
raise NotChatAppError()
|
||||
|
||||
AppTaskService.stop_task(
|
||||
|
||||
@ -1,9 +1,12 @@
|
||||
from collections.abc import Mapping
|
||||
from typing import Any, TypedDict
|
||||
from typing import TYPE_CHECKING, Any, TypedDict
|
||||
|
||||
from configs import dify_config
|
||||
from constants import DEFAULT_FILE_NUMBER_LIMITS
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from models.model import App
|
||||
|
||||
|
||||
class FeatureToggleDict(TypedDict):
|
||||
enabled: bool
|
||||
@ -70,3 +73,39 @@ def get_parameters_from_feature_dict(
|
||||
"workflow_file_upload_limit": dify_config.WORKFLOW_FILE_UPLOAD_LIMIT,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
class AppParametersUnavailableError(Exception):
|
||||
"""Raised when an app cannot yet expose webapp parameters (no published config)."""
|
||||
|
||||
|
||||
def get_app_parameters(app_model: "App") -> AppParametersDict:
|
||||
"""Resolve the webapp parameters for any app type.
|
||||
|
||||
Workflow / advanced-chat apps read their feature flags from the bound
|
||||
workflow; easy-UI apps (chat / agent-chat / completion) from their
|
||||
``app_model_config``. An Agent App has neither a workflow nor a legacy
|
||||
``app_model_config`` — its presentation features are not yet configurable,
|
||||
so every toggle defaults to disabled with a free-form chat input.
|
||||
"""
|
||||
from models.model import AppMode
|
||||
|
||||
features_dict: Mapping[str, Any]
|
||||
user_input_form: list[dict[str, Any]]
|
||||
|
||||
if app_model.mode in {AppMode.ADVANCED_CHAT, AppMode.WORKFLOW}:
|
||||
workflow = app_model.workflow
|
||||
if workflow is None:
|
||||
raise AppParametersUnavailableError()
|
||||
features_dict = workflow.features_dict
|
||||
user_input_form = workflow.user_input_form(to_old_structure=True)
|
||||
elif app_model.app_model_config is not None:
|
||||
features_dict = app_model.app_model_config.to_dict()
|
||||
user_input_form = features_dict.get("user_input_form", [])
|
||||
elif app_model.mode == AppMode.AGENT:
|
||||
features_dict = {}
|
||||
user_input_form = []
|
||||
else:
|
||||
raise AppParametersUnavailableError()
|
||||
|
||||
return get_parameters_from_feature_dict(features_dict=features_dict, user_input_form=user_input_form)
|
||||
|
||||
0
api/core/app/apps/agent_app/__init__.py
Normal file
0
api/core/app/apps/agent_app/__init__.py
Normal file
105
api/core/app/apps/agent_app/app_config_manager.py
Normal file
105
api/core/app/apps/agent_app/app_config_manager.py
Normal file
@ -0,0 +1,105 @@
|
||||
"""Build the EasyUI-style app config for an Agent App from its Agent Soul.
|
||||
|
||||
An Agent App has no legacy ``app_model_config``: its model / prompt live in the
|
||||
bound Agent Soul snapshot. To ride the existing chat message + SSE pipeline we
|
||||
synthesize an ``app_model_config``-shaped dict from the Soul (model + system
|
||||
prompt) plus any app-level feature flags (opening statement, follow-up, …)
|
||||
stored on ``app_model_config`` when present, then reuse the same sub-managers
|
||||
the chat app type uses.
|
||||
"""
|
||||
|
||||
from typing import Any
|
||||
|
||||
from core.app.app_config.base_app_config_manager import BaseAppConfigManager
|
||||
from core.app.app_config.common.sensitive_word_avoidance.manager import SensitiveWordAvoidanceConfigManager
|
||||
from core.app.app_config.easy_ui_based_app.dataset.manager import DatasetConfigManager
|
||||
from core.app.app_config.easy_ui_based_app.model_config.manager import ModelConfigManager
|
||||
from core.app.app_config.easy_ui_based_app.prompt_template.manager import PromptTemplateConfigManager
|
||||
from core.app.app_config.easy_ui_based_app.variables.manager import BasicVariablesConfigManager
|
||||
from core.app.app_config.entities import (
|
||||
EasyUIBasedAppConfig,
|
||||
EasyUIBasedAppModelConfigFrom,
|
||||
PromptTemplateEntity,
|
||||
)
|
||||
from models.agent_config_entities import AgentSoulConfig
|
||||
from models.model import App, AppMode, AppModelConfig, Conversation
|
||||
|
||||
|
||||
class AgentAppConfig(EasyUIBasedAppConfig):
|
||||
"""Agent App config entity (EasyUI-shaped so it rides the chat pipeline).
|
||||
|
||||
Unlike legacy EasyUI apps, an Agent App has no ``app_model_config`` row, so
|
||||
the id may be absent; persistence stores ``NULL`` for the conversation's
|
||||
``app_model_config_id`` in that case.
|
||||
"""
|
||||
|
||||
app_model_config_id: str | None = None
|
||||
|
||||
|
||||
class AgentAppConfigManager(BaseAppConfigManager):
|
||||
@classmethod
|
||||
def get_app_config(
|
||||
cls,
|
||||
*,
|
||||
app_model: App,
|
||||
agent_soul: AgentSoulConfig,
|
||||
app_model_config: AppModelConfig | None = None,
|
||||
conversation: Conversation | None = None,
|
||||
) -> AgentAppConfig:
|
||||
"""Build the Agent App config from the Agent Soul (+ optional feature flags)."""
|
||||
config_dict = cls._synthesize_config_dict(agent_soul, app_model_config)
|
||||
app_mode = AppMode.value_of(app_model.mode)
|
||||
|
||||
app_config = AgentAppConfig(
|
||||
tenant_id=app_model.tenant_id,
|
||||
app_id=app_model.id,
|
||||
app_mode=app_mode,
|
||||
# The config is derived from the Agent Soul snapshot, not a legacy
|
||||
# app_model_config row; the id is informational only.
|
||||
app_model_config_from=EasyUIBasedAppModelConfigFrom.APP_LATEST_CONFIG,
|
||||
app_model_config_id=app_model_config.id if app_model_config else None,
|
||||
app_model_config_dict=config_dict,
|
||||
model=ModelConfigManager.convert(config=config_dict),
|
||||
prompt_template=PromptTemplateConfigManager.convert(config=config_dict),
|
||||
sensitive_word_avoidance=SensitiveWordAvoidanceConfigManager.convert(config=config_dict),
|
||||
dataset=DatasetConfigManager.convert(config=config_dict),
|
||||
additional_features=cls.convert_features(config_dict, app_mode),
|
||||
)
|
||||
app_config.variables, app_config.external_data_variables = BasicVariablesConfigManager.convert(
|
||||
config=config_dict
|
||||
)
|
||||
return app_config
|
||||
|
||||
@staticmethod
|
||||
def _synthesize_config_dict(
|
||||
agent_soul: AgentSoulConfig,
|
||||
app_model_config: AppModelConfig | None,
|
||||
) -> dict[str, Any]:
|
||||
"""Shape a Soul + feature flags into an ``app_model_config``-style dict.
|
||||
|
||||
Feature flags (opening statement / follow-up / tts / stt / citations /
|
||||
moderation / annotation) come from ``app_model_config`` when present
|
||||
(Q3: stored there), otherwise defaults; model + prompt always come from
|
||||
the Agent Soul (the single source of truth for those).
|
||||
"""
|
||||
base: dict[str, Any] = app_model_config.to_dict() if app_model_config else {}
|
||||
|
||||
model = agent_soul.model
|
||||
if model is not None:
|
||||
base["model"] = {
|
||||
"provider": model.model_provider,
|
||||
"name": model.model,
|
||||
"mode": "chat",
|
||||
"completion_params": dict(model.model_settings or {}),
|
||||
}
|
||||
# The Agent Soul system prompt rides the EasyUI "simple" prompt slot; the
|
||||
# agent backend is the real prompt authority, this only feeds the chat
|
||||
# pipeline's bookkeeping (token counting, persistence).
|
||||
base["prompt_type"] = PromptTemplateEntity.PromptType.SIMPLE.value
|
||||
base["pre_prompt"] = agent_soul.prompt.system_prompt or ""
|
||||
# Agent App takes the user message directly; no completion-style inputs form.
|
||||
base.setdefault("user_input_form", [])
|
||||
return base
|
||||
|
||||
|
||||
__all__ = ["AgentAppConfig", "AgentAppConfigManager"]
|
||||
252
api/core/app/apps/agent_app/app_generator.py
Normal file
252
api/core/app/apps/agent_app/app_generator.py
Normal file
@ -0,0 +1,252 @@
|
||||
"""Agent App generator: orchestrate one conversation turn for an Agent App.
|
||||
|
||||
Mirrors the agent_chat generator (conversation + message + queue + streamed
|
||||
response over the EasyUI chat pipeline), but the backing config comes from the
|
||||
bound Agent Soul and the answer is produced by ``AgentAppRunner`` calling the
|
||||
dify-agent backend rather than an in-process LLM/ReAct loop.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import contextvars
|
||||
import logging
|
||||
import threading
|
||||
import uuid
|
||||
from collections.abc import Generator, Mapping
|
||||
from typing import Any
|
||||
|
||||
from flask import Flask, current_app
|
||||
from sqlalchemy import select
|
||||
|
||||
from clients.agent_backend import AgentBackendRunEventAdapter
|
||||
from clients.agent_backend.factory import create_agent_backend_run_client
|
||||
from configs import dify_config
|
||||
from constants import UUID_NIL
|
||||
from core.app.app_config.easy_ui_based_app.model_config.converter import ModelConfigConverter
|
||||
from core.app.apps.agent_app.app_config_manager import AgentAppConfigManager
|
||||
from core.app.apps.agent_app.app_runner import AgentAppRunner
|
||||
from core.app.apps.agent_app.generate_response_converter import AgentAppGenerateResponseConverter
|
||||
from core.app.apps.agent_app.runtime_request_builder import AgentAppRuntimeRequestBuilder
|
||||
from core.app.apps.agent_app.session_store import AgentAppRuntimeSessionStore
|
||||
from core.app.apps.base_app_queue_manager import AppQueueManager, PublishFrom
|
||||
from core.app.apps.exc import GenerateTaskStoppedError
|
||||
from core.app.apps.message_based_app_generator import MessageBasedAppGenerator
|
||||
from core.app.apps.message_based_app_queue_manager import MessageBasedAppQueueManager
|
||||
from core.app.entities.app_invoke_entities import (
|
||||
AgentAppGenerateEntity,
|
||||
DifyRunContext,
|
||||
InvokeFrom,
|
||||
UserFrom,
|
||||
)
|
||||
from core.app.llm.model_access import build_dify_model_access
|
||||
from core.ops.ops_trace_manager import TraceQueueManager
|
||||
from extensions.ext_database import db
|
||||
from models import Account, App, EndUser
|
||||
from models.agent import Agent, AgentConfigSnapshot, AgentScope, AgentSource, AgentStatus
|
||||
from models.agent_config_entities import AgentSoulConfig
|
||||
from services.conversation_service import ConversationService
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class AgentAppGeneratorError(ValueError):
|
||||
"""Raised when an Agent App turn cannot be set up."""
|
||||
|
||||
|
||||
class AgentAppGenerator(MessageBasedAppGenerator):
|
||||
def generate(
|
||||
self,
|
||||
*,
|
||||
app_model: App,
|
||||
user: Account | EndUser,
|
||||
args: Mapping[str, Any],
|
||||
invoke_from: InvokeFrom,
|
||||
streaming: bool = True,
|
||||
) -> Mapping[str, Any] | Generator[Mapping | str, None, None]:
|
||||
if not streaming:
|
||||
raise AgentAppGeneratorError("Agent App only supports streaming mode")
|
||||
|
||||
query = args.get("query")
|
||||
if not isinstance(query, str) or not query.strip():
|
||||
raise AgentAppGeneratorError("query is required")
|
||||
query = query.replace("\x00", "")
|
||||
inputs = args["inputs"]
|
||||
|
||||
# Resolve the bound roster Agent + its published Agent Soul snapshot.
|
||||
agent, snapshot, agent_soul = self._resolve_agent(app_model)
|
||||
|
||||
conversation = None
|
||||
conversation_id = args.get("conversation_id")
|
||||
if conversation_id:
|
||||
conversation = ConversationService.get_conversation(
|
||||
app_model=app_model, conversation_id=conversation_id, user=user
|
||||
)
|
||||
|
||||
# Build the EasyUI-shaped config from the Agent Soul so the chat pipeline
|
||||
# can persist usage; the answer itself comes from the agent backend.
|
||||
app_model_config = app_model.app_model_config
|
||||
app_config = AgentAppConfigManager.get_app_config(
|
||||
app_model=app_model,
|
||||
agent_soul=agent_soul,
|
||||
app_model_config=app_model_config,
|
||||
conversation=conversation,
|
||||
)
|
||||
model_conf = ModelConfigConverter.convert(app_config)
|
||||
|
||||
trace_manager = TraceQueueManager(app_model.id, user.id if isinstance(user, Account) else user.session_id)
|
||||
|
||||
application_generate_entity = AgentAppGenerateEntity(
|
||||
task_id=str(uuid.uuid4()),
|
||||
app_config=app_config,
|
||||
model_conf=model_conf,
|
||||
conversation_id=conversation.id if conversation else None,
|
||||
inputs=self._prepare_user_inputs(
|
||||
user_inputs=inputs, variables=app_config.variables, tenant_id=app_model.tenant_id
|
||||
),
|
||||
query=query,
|
||||
files=[],
|
||||
parent_message_id=(
|
||||
args.get("parent_message_id")
|
||||
if invoke_from not in {InvokeFrom.SERVICE_API, InvokeFrom.OPENAPI}
|
||||
else UUID_NIL
|
||||
),
|
||||
user_id=user.id,
|
||||
stream=streaming,
|
||||
invoke_from=invoke_from,
|
||||
extras={"auto_generate_conversation_name": args.get("auto_generate_name", True)},
|
||||
call_depth=0,
|
||||
trace_manager=trace_manager,
|
||||
agent_id=agent.id,
|
||||
agent_config_snapshot_id=snapshot.id,
|
||||
)
|
||||
|
||||
conversation, message = self._init_generate_records(application_generate_entity, conversation)
|
||||
|
||||
queue_manager = MessageBasedAppQueueManager(
|
||||
task_id=application_generate_entity.task_id,
|
||||
user_id=application_generate_entity.user_id,
|
||||
invoke_from=application_generate_entity.invoke_from,
|
||||
conversation_id=conversation.id,
|
||||
app_mode=conversation.mode,
|
||||
message_id=message.id,
|
||||
)
|
||||
|
||||
context = contextvars.copy_context()
|
||||
worker_thread = threading.Thread(
|
||||
target=self._generate_worker,
|
||||
kwargs={
|
||||
"flask_app": current_app._get_current_object(), # type: ignore
|
||||
"context": context,
|
||||
"application_generate_entity": application_generate_entity,
|
||||
"queue_manager": queue_manager,
|
||||
"conversation_id": conversation.id,
|
||||
"message_id": message.id,
|
||||
"user_from": UserFrom.ACCOUNT if isinstance(user, Account) else UserFrom.END_USER,
|
||||
},
|
||||
)
|
||||
worker_thread.start()
|
||||
|
||||
response = self._handle_response(
|
||||
application_generate_entity=application_generate_entity,
|
||||
queue_manager=queue_manager,
|
||||
conversation=conversation,
|
||||
message=message,
|
||||
user=user,
|
||||
stream=streaming,
|
||||
)
|
||||
return AgentAppGenerateResponseConverter.convert(response=response, invoke_from=invoke_from)
|
||||
|
||||
def _generate_worker(
|
||||
self,
|
||||
*,
|
||||
flask_app: Flask,
|
||||
context: contextvars.Context,
|
||||
application_generate_entity: AgentAppGenerateEntity,
|
||||
queue_manager: AppQueueManager,
|
||||
conversation_id: str,
|
||||
message_id: str,
|
||||
user_from: UserFrom,
|
||||
) -> None:
|
||||
from libs.flask_utils import preserve_flask_contexts
|
||||
|
||||
with preserve_flask_contexts(flask_app, context_vars=context):
|
||||
try:
|
||||
conversation = self._get_conversation(conversation_id)
|
||||
message = self._get_message(message_id)
|
||||
app_config = application_generate_entity.app_config
|
||||
|
||||
dify_context = DifyRunContext(
|
||||
tenant_id=app_config.tenant_id,
|
||||
app_id=app_config.app_id,
|
||||
user_id=application_generate_entity.user_id,
|
||||
user_from=user_from,
|
||||
invoke_from=application_generate_entity.invoke_from,
|
||||
)
|
||||
credentials_provider, _ = build_dify_model_access(dify_context)
|
||||
_, _, agent_soul = self._resolve_agent_by_id(
|
||||
tenant_id=app_config.tenant_id,
|
||||
agent_id=application_generate_entity.agent_id,
|
||||
snapshot_id=application_generate_entity.agent_config_snapshot_id,
|
||||
)
|
||||
|
||||
runner = AgentAppRunner(
|
||||
request_builder=AgentAppRuntimeRequestBuilder(credentials_provider=credentials_provider),
|
||||
agent_backend_client=create_agent_backend_run_client(
|
||||
base_url=dify_config.AGENT_BACKEND_BASE_URL,
|
||||
use_fake=dify_config.AGENT_BACKEND_USE_FAKE,
|
||||
fake_scenario=dify_config.AGENT_BACKEND_FAKE_SCENARIO,
|
||||
),
|
||||
event_adapter=AgentBackendRunEventAdapter(),
|
||||
session_store=AgentAppRuntimeSessionStore(),
|
||||
)
|
||||
runner.run(
|
||||
dify_context=dify_context,
|
||||
agent_id=application_generate_entity.agent_id,
|
||||
agent_config_snapshot_id=application_generate_entity.agent_config_snapshot_id,
|
||||
agent_soul=agent_soul,
|
||||
conversation_id=conversation.id,
|
||||
query=application_generate_entity.query,
|
||||
message_id=message.id,
|
||||
model_name=application_generate_entity.model_conf.model,
|
||||
queue_manager=queue_manager,
|
||||
)
|
||||
except GenerateTaskStoppedError:
|
||||
pass
|
||||
except Exception as e:
|
||||
logger.exception("Unknown Error in Agent App generate worker")
|
||||
queue_manager.publish_error(e, PublishFrom.APPLICATION_MANAGER)
|
||||
finally:
|
||||
db.session.close()
|
||||
|
||||
def _resolve_agent(self, app_model: App) -> tuple[Agent, AgentConfigSnapshot, AgentSoulConfig]:
|
||||
agent = db.session.scalar(
|
||||
select(Agent).where(
|
||||
Agent.app_id == app_model.id,
|
||||
Agent.scope == AgentScope.ROSTER,
|
||||
Agent.source == AgentSource.AGENT_APP,
|
||||
Agent.status == AgentStatus.ACTIVE,
|
||||
)
|
||||
)
|
||||
if agent is None:
|
||||
raise AgentAppGeneratorError("Agent App has no bound Agent")
|
||||
return self._resolve_agent_by_id(
|
||||
tenant_id=app_model.tenant_id, agent_id=agent.id, snapshot_id=agent.active_config_snapshot_id
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _resolve_agent_by_id(
|
||||
*, tenant_id: str, agent_id: str, snapshot_id: str | None
|
||||
) -> tuple[Agent, AgentConfigSnapshot, AgentSoulConfig]:
|
||||
agent = db.session.scalar(select(Agent).where(Agent.id == agent_id, Agent.tenant_id == tenant_id))
|
||||
if agent is None:
|
||||
raise AgentAppGeneratorError("Agent not found")
|
||||
if not snapshot_id:
|
||||
raise AgentAppGeneratorError("Agent has no published version")
|
||||
snapshot = db.session.scalar(select(AgentConfigSnapshot).where(AgentConfigSnapshot.id == snapshot_id))
|
||||
if snapshot is None:
|
||||
raise AgentAppGeneratorError("Agent published version not found")
|
||||
agent_soul = AgentSoulConfig.model_validate(snapshot.config_snapshot_dict)
|
||||
return agent, snapshot, agent_soul
|
||||
|
||||
|
||||
__all__ = ["AgentAppGenerator", "AgentAppGeneratorError"]
|
||||
175
api/core/app/apps/agent_app/app_runner.py
Normal file
175
api/core/app/apps/agent_app/app_runner.py
Normal file
@ -0,0 +1,175 @@
|
||||
"""Agent App runner: drive one conversation turn through the dify-agent backend.
|
||||
|
||||
Unlike the legacy ``AgentChatAppRunner`` (which runs an in-process ReAct loop),
|
||||
this runner delegates to the Agent backend: build the run request from the
|
||||
Agent Soul + conversation, create the run, consume its event stream, and
|
||||
republish the assistant answer as chat queue events so the existing
|
||||
EasyUI chat task pipeline persists the message and streams SSE. The conversation
|
||||
``session_snapshot`` is saved on success for multi-turn continuity (S3).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
from pydantic import JsonValue
|
||||
|
||||
from clients.agent_backend import (
|
||||
AgentBackendError,
|
||||
AgentBackendInternalEventType,
|
||||
AgentBackendRunClient,
|
||||
AgentBackendRunEventAdapter,
|
||||
AgentBackendRunSucceededInternalEvent,
|
||||
AgentBackendStreamInternalEvent,
|
||||
)
|
||||
from core.app.apps.agent_app.runtime_request_builder import (
|
||||
AgentAppRuntimeBuildContext,
|
||||
AgentAppRuntimeRequestBuilder,
|
||||
)
|
||||
from core.app.apps.agent_app.session_store import AgentAppRuntimeSessionStore, AgentAppSessionScope
|
||||
from core.app.apps.base_app_queue_manager import AppQueueManager, PublishFrom
|
||||
from core.app.entities.app_invoke_entities import DifyRunContext
|
||||
from core.app.entities.queue_entities import QueueLLMChunkEvent, QueueMessageEndEvent
|
||||
from graphon.model_runtime.entities.llm_entities import LLMResult, LLMResultChunk, LLMResultChunkDelta, LLMUsage
|
||||
from graphon.model_runtime.entities.message_entities import AssistantPromptMessage
|
||||
from models.agent_config_entities import AgentSoulConfig
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class AgentAppRunner:
|
||||
"""Runs one Agent App conversation turn against the Agent backend."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
request_builder: AgentAppRuntimeRequestBuilder,
|
||||
agent_backend_client: AgentBackendRunClient,
|
||||
event_adapter: AgentBackendRunEventAdapter,
|
||||
session_store: AgentAppRuntimeSessionStore,
|
||||
) -> None:
|
||||
self._request_builder = request_builder
|
||||
self._agent_backend_client = agent_backend_client
|
||||
self._event_adapter = event_adapter
|
||||
self._session_store = session_store
|
||||
|
||||
def run(
|
||||
self,
|
||||
*,
|
||||
dify_context: DifyRunContext,
|
||||
agent_id: str,
|
||||
agent_config_snapshot_id: str,
|
||||
agent_soul: AgentSoulConfig,
|
||||
conversation_id: str,
|
||||
query: str,
|
||||
message_id: str,
|
||||
model_name: str,
|
||||
queue_manager: AppQueueManager,
|
||||
) -> None:
|
||||
scope = AgentAppSessionScope(
|
||||
tenant_id=dify_context.tenant_id,
|
||||
app_id=dify_context.app_id,
|
||||
conversation_id=conversation_id,
|
||||
agent_id=agent_id,
|
||||
)
|
||||
session_snapshot = self._session_store.load_active_snapshot(scope)
|
||||
|
||||
runtime = self._request_builder.build(
|
||||
AgentAppRuntimeBuildContext(
|
||||
dify_context=dify_context,
|
||||
agent_id=agent_id,
|
||||
agent_config_snapshot_id=agent_config_snapshot_id,
|
||||
agent_soul=agent_soul,
|
||||
conversation_id=conversation_id,
|
||||
user_query=query,
|
||||
idempotency_key=message_id,
|
||||
session_snapshot=session_snapshot,
|
||||
)
|
||||
)
|
||||
|
||||
create_response = self._agent_backend_client.create_run(runtime.request)
|
||||
terminal = self._consume_stream(create_response.run_id)
|
||||
|
||||
if not isinstance(terminal, AgentBackendRunSucceededInternalEvent):
|
||||
error = getattr(terminal, "error", None) or "Agent backend run did not complete successfully."
|
||||
raise AgentBackendError(str(error))
|
||||
|
||||
answer = self._extract_answer(terminal.output)
|
||||
self._publish_answer(queue_manager=queue_manager, model_name=model_name, answer=answer)
|
||||
self._save_session(scope=scope, backend_run_id=terminal.run_id, snapshot=terminal.session_snapshot)
|
||||
|
||||
def _consume_stream(self, run_id: str):
|
||||
terminal = None
|
||||
for public_event in self._agent_backend_client.stream_events(run_id):
|
||||
for internal_event in self._event_adapter.adapt(public_event):
|
||||
if internal_event.type in (
|
||||
AgentBackendInternalEventType.RUN_STARTED,
|
||||
AgentBackendInternalEventType.STREAM_EVENT,
|
||||
):
|
||||
# Stream deltas are accumulated by the backend into the
|
||||
# terminal output; token-level forwarding is an S3 refinement.
|
||||
if isinstance(internal_event, AgentBackendStreamInternalEvent):
|
||||
continue
|
||||
continue
|
||||
terminal = internal_event
|
||||
break
|
||||
if terminal is not None:
|
||||
break
|
||||
return terminal
|
||||
|
||||
def _publish_answer(self, *, queue_manager: AppQueueManager, model_name: str, answer: str) -> None:
|
||||
# MVP: emit the full answer as a single chunk + message-end. The chat
|
||||
# task pipeline streams the chunk over SSE and persists the message.
|
||||
chunk = LLMResultChunk(
|
||||
model=model_name,
|
||||
prompt_messages=[],
|
||||
delta=LLMResultChunkDelta(index=0, message=AssistantPromptMessage(content=answer)),
|
||||
)
|
||||
queue_manager.publish(QueueLLMChunkEvent(chunk=chunk), PublishFrom.APPLICATION_MANAGER)
|
||||
queue_manager.publish(
|
||||
QueueMessageEndEvent(
|
||||
llm_result=LLMResult(
|
||||
model=model_name,
|
||||
prompt_messages=[],
|
||||
message=AssistantPromptMessage(content=answer),
|
||||
usage=LLMUsage.empty_usage(),
|
||||
),
|
||||
),
|
||||
PublishFrom.APPLICATION_MANAGER,
|
||||
)
|
||||
|
||||
def _save_session(self, *, scope: AgentAppSessionScope, backend_run_id: str, snapshot: Any) -> None:
|
||||
try:
|
||||
self._session_store.save_active_snapshot(scope=scope, backend_run_id=backend_run_id, snapshot=snapshot)
|
||||
except Exception:
|
||||
logger.warning(
|
||||
"Failed to persist Agent App conversation session snapshot: "
|
||||
"tenant_id=%s app_id=%s conversation_id=%s agent_id=%s",
|
||||
scope.tenant_id,
|
||||
scope.app_id,
|
||||
scope.conversation_id,
|
||||
scope.agent_id,
|
||||
exc_info=True,
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _extract_answer(output: JsonValue) -> str:
|
||||
"""Normalize the backend's terminal output to assistant text.
|
||||
|
||||
Free-text Agent Apps return a plain string; if a structured output is
|
||||
configured the value is a JSON object, which we serialize so the chat
|
||||
message always has a string body.
|
||||
"""
|
||||
if isinstance(output, str):
|
||||
return output
|
||||
if isinstance(output, dict):
|
||||
text = output.get("text")
|
||||
if isinstance(text, str):
|
||||
return text
|
||||
return json.dumps(output, ensure_ascii=False)
|
||||
return json.dumps(output, ensure_ascii=False)
|
||||
|
||||
|
||||
__all__ = ["AgentAppRunner"]
|
||||
15
api/core/app/apps/agent_app/generate_response_converter.py
Normal file
15
api/core/app/apps/agent_app/generate_response_converter.py
Normal file
@ -0,0 +1,15 @@
|
||||
"""Response converter for the Agent App type.
|
||||
|
||||
The Agent App streams the same chatbot response shape as the chat / agent-chat
|
||||
app types, so it reuses that converter wholesale; kept as a distinct subclass so
|
||||
the app type owns its converter and can diverge later.
|
||||
"""
|
||||
|
||||
from core.app.apps.agent_chat.generate_response_converter import AgentChatAppGenerateResponseConverter
|
||||
|
||||
|
||||
class AgentAppGenerateResponseConverter(AgentChatAppGenerateResponseConverter):
|
||||
pass
|
||||
|
||||
|
||||
__all__ = ["AgentAppGenerateResponseConverter"]
|
||||
177
api/core/app/apps/agent_app/runtime_request_builder.py
Normal file
177
api/core/app/apps/agent_app/runtime_request_builder.py
Normal file
@ -0,0 +1,177 @@
|
||||
"""Build dify-agent run requests for one Agent App conversation turn.
|
||||
|
||||
Mirrors the workflow ``WorkflowAgentRuntimeRequestBuilder`` but for the Agent
|
||||
App surface: the user prompt is the chat message (no workflow-node job / no
|
||||
previous-node context), and multi-turn continuity flows through the
|
||||
conversation-keyed ``session_snapshot`` plus the history layer.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Mapping
|
||||
from dataclasses import dataclass
|
||||
from typing import Any, Protocol, cast
|
||||
|
||||
from agenton.compositor import CompositorSessionSnapshot
|
||||
from dify_agent.layers.execution_context import DifyExecutionContextLayerConfig
|
||||
from dify_agent.protocol import CreateRunRequest
|
||||
|
||||
from clients.agent_backend import (
|
||||
AgentBackendAgentAppRunInput,
|
||||
AgentBackendModelConfig,
|
||||
AgentBackendRunRequestBuilder,
|
||||
redact_for_agent_backend_log,
|
||||
)
|
||||
from core.app.entities.app_invoke_entities import DifyRunContext
|
||||
from core.workflow.nodes.agent_v2.plugin_tools_builder import (
|
||||
WorkflowAgentPluginToolsBuilder,
|
||||
WorkflowAgentPluginToolsBuildError,
|
||||
)
|
||||
from models.agent_config_entities import AgentSoulConfig
|
||||
from models.provider_ids import ModelProviderID
|
||||
|
||||
|
||||
class AgentAppRuntimeRequestBuildError(ValueError):
|
||||
"""Raised when Agent App state cannot be mapped to a valid run request."""
|
||||
|
||||
def __init__(self, error_code: str, message: str) -> None:
|
||||
self.error_code = error_code
|
||||
super().__init__(message)
|
||||
|
||||
|
||||
class CredentialsProvider(Protocol):
|
||||
def fetch(self, provider_name: str, model_name: str) -> dict[str, Any]: ...
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class AgentAppRuntimeBuildContext:
|
||||
dify_context: DifyRunContext
|
||||
agent_id: str
|
||||
agent_config_snapshot_id: str
|
||||
agent_soul: AgentSoulConfig
|
||||
conversation_id: str
|
||||
user_query: str
|
||||
idempotency_key: str
|
||||
session_snapshot: CompositorSessionSnapshot | None = None
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class AgentAppRuntimeRequest:
|
||||
request: CreateRunRequest
|
||||
redacted_request: dict[str, Any]
|
||||
metadata: dict[str, Any]
|
||||
|
||||
|
||||
class AgentAppRuntimeRequestBuilder:
|
||||
"""Build dify-agent run requests from Agent App conversation state."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
credentials_provider: CredentialsProvider,
|
||||
request_builder: AgentBackendRunRequestBuilder | None = None,
|
||||
plugin_tools_builder: WorkflowAgentPluginToolsBuilder | None = None,
|
||||
) -> None:
|
||||
self._credentials_provider = credentials_provider
|
||||
self._request_builder = request_builder or AgentBackendRunRequestBuilder()
|
||||
self._plugin_tools_builder = plugin_tools_builder or WorkflowAgentPluginToolsBuilder()
|
||||
|
||||
def build(self, context: AgentAppRuntimeBuildContext) -> AgentAppRuntimeRequest:
|
||||
agent_soul = context.agent_soul
|
||||
if agent_soul.model is None:
|
||||
raise AgentAppRuntimeRequestBuildError(
|
||||
"agent_model_not_configured",
|
||||
"Agent App requires the Agent Soul model to be configured.",
|
||||
)
|
||||
|
||||
metadata = self._build_metadata(context)
|
||||
credentials = self._credentials_provider.fetch(agent_soul.model.model_provider, agent_soul.model.model)
|
||||
try:
|
||||
tools_layer = self._plugin_tools_builder.build(
|
||||
tenant_id=context.dify_context.tenant_id,
|
||||
app_id=context.dify_context.app_id,
|
||||
user_id=context.dify_context.user_id,
|
||||
tools=agent_soul.tools,
|
||||
invoke_from=context.dify_context.invoke_from,
|
||||
)
|
||||
except WorkflowAgentPluginToolsBuildError as error:
|
||||
raise AgentAppRuntimeRequestBuildError(error.error_code, str(error)) from error
|
||||
if tools_layer is not None:
|
||||
metadata["agent_tools"] = {
|
||||
"dify_tool_count": len(tools_layer.tools),
|
||||
"dify_tool_names": [tool.name or tool.tool_name for tool in tools_layer.tools],
|
||||
}
|
||||
|
||||
request = self._request_builder.build_for_agent_app(
|
||||
AgentBackendAgentAppRunInput(
|
||||
model=AgentBackendModelConfig(
|
||||
plugin_id=self._plugin_daemon_plugin_id(
|
||||
plugin_id=agent_soul.model.plugin_id,
|
||||
model_provider=agent_soul.model.model_provider,
|
||||
),
|
||||
model_provider=self._plugin_daemon_provider_name(agent_soul.model.model_provider),
|
||||
model=agent_soul.model.model,
|
||||
credentials=self._normalize_credentials(credentials),
|
||||
model_settings=agent_soul.model.model_settings,
|
||||
),
|
||||
execution_context=DifyExecutionContextLayerConfig(
|
||||
tenant_id=context.dify_context.tenant_id,
|
||||
user_id=context.dify_context.user_id,
|
||||
app_id=context.dify_context.app_id,
|
||||
conversation_id=context.conversation_id,
|
||||
agent_id=context.agent_id,
|
||||
agent_config_version_id=context.agent_config_snapshot_id,
|
||||
invoke_from="agent_app",
|
||||
),
|
||||
agent_soul_prompt=agent_soul.prompt.system_prompt or None,
|
||||
user_prompt=context.user_query,
|
||||
tools=tools_layer,
|
||||
session_snapshot=context.session_snapshot,
|
||||
idempotency_key=context.idempotency_key,
|
||||
metadata=metadata,
|
||||
)
|
||||
)
|
||||
redacted = cast(dict[str, Any], redact_for_agent_backend_log(request))
|
||||
return AgentAppRuntimeRequest(request=request, redacted_request=redacted, metadata=metadata)
|
||||
|
||||
@staticmethod
|
||||
def _build_metadata(context: AgentAppRuntimeBuildContext) -> dict[str, Any]:
|
||||
return {
|
||||
"tenant_id": context.dify_context.tenant_id,
|
||||
"app_id": context.dify_context.app_id,
|
||||
"conversation_id": context.conversation_id,
|
||||
"agent_id": context.agent_id,
|
||||
"agent_config_snapshot_id": context.agent_config_snapshot_id,
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def _plugin_daemon_plugin_id(*, plugin_id: str, model_provider: str) -> str:
|
||||
"""Return the transport plugin id expected by plugin-daemon headers."""
|
||||
if plugin_id.count("/") == 1:
|
||||
return plugin_id
|
||||
if plugin_id:
|
||||
return ModelProviderID(plugin_id).plugin_id
|
||||
return ModelProviderID(model_provider).plugin_id
|
||||
|
||||
@staticmethod
|
||||
def _plugin_daemon_provider_name(model_provider: str) -> str:
|
||||
"""Return the provider name expected by plugin-daemon dispatch payloads."""
|
||||
return ModelProviderID(model_provider).provider_name
|
||||
|
||||
@staticmethod
|
||||
def _normalize_credentials(credentials: Mapping[str, Any]) -> dict[str, str | int | float | bool | None]:
|
||||
normalized: dict[str, str | int | float | bool | None] = {}
|
||||
for key, value in credentials.items():
|
||||
if isinstance(value, str | int | float | bool) or value is None:
|
||||
normalized[key] = value
|
||||
else:
|
||||
normalized[key] = str(value)
|
||||
return normalized
|
||||
|
||||
|
||||
__all__ = [
|
||||
"AgentAppRuntimeBuildContext",
|
||||
"AgentAppRuntimeRequest",
|
||||
"AgentAppRuntimeRequestBuildError",
|
||||
"AgentAppRuntimeRequestBuilder",
|
||||
]
|
||||
103
api/core/app/apps/agent_app/session_store.py
Normal file
103
api/core/app/apps/agent_app/session_store.py
Normal file
@ -0,0 +1,103 @@
|
||||
"""Conversation-keyed Agent backend session store for the Agent App type.
|
||||
|
||||
Shares the unified ``agent_runtime_sessions`` table with the workflow Agent
|
||||
Node store, but owns rows with ``owner_type = conversation``: one Agent App
|
||||
conversation maps to one Agent session, so multi-turn chat re-enters the same
|
||||
``session_snapshot``. Cross-conversation memory (PRD Global / Per app) is a
|
||||
phase-2 concern and not modeled here.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
|
||||
from agenton.compositor import CompositorSessionSnapshot
|
||||
from sqlalchemy import select
|
||||
|
||||
from core.db.session_factory import session_factory
|
||||
from libs.datetime_utils import naive_utc_now
|
||||
from models.agent import (
|
||||
AgentRuntimeSession,
|
||||
AgentRuntimeSessionOwnerType,
|
||||
AgentRuntimeSessionStatus,
|
||||
)
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class AgentAppSessionScope:
|
||||
"""Identity of one Agent App conversation session."""
|
||||
|
||||
tenant_id: str
|
||||
app_id: str
|
||||
conversation_id: str
|
||||
agent_id: str
|
||||
|
||||
|
||||
class AgentAppRuntimeSessionStore:
|
||||
"""Persists Agent backend session snapshots for Agent App conversations."""
|
||||
|
||||
def load_active_snapshot(self, scope: AgentAppSessionScope) -> CompositorSessionSnapshot | None:
|
||||
with session_factory.create_session() as session:
|
||||
row = session.scalar(self._active_stmt(scope))
|
||||
if row is None:
|
||||
return None
|
||||
return CompositorSessionSnapshot.model_validate_json(row.session_snapshot)
|
||||
|
||||
def save_active_snapshot(
|
||||
self,
|
||||
*,
|
||||
scope: AgentAppSessionScope,
|
||||
backend_run_id: str,
|
||||
snapshot: CompositorSessionSnapshot | None,
|
||||
) -> None:
|
||||
if snapshot is None:
|
||||
return
|
||||
snapshot_json = snapshot.model_dump_json()
|
||||
with session_factory.create_session() as session:
|
||||
row = session.scalar(self._scope_stmt(scope))
|
||||
if row is None:
|
||||
row = AgentRuntimeSession(
|
||||
tenant_id=scope.tenant_id,
|
||||
app_id=scope.app_id,
|
||||
owner_type=AgentRuntimeSessionOwnerType.CONVERSATION,
|
||||
agent_id=scope.agent_id,
|
||||
conversation_id=scope.conversation_id,
|
||||
backend_run_id=backend_run_id,
|
||||
session_snapshot=snapshot_json,
|
||||
composition_layer_specs="[]",
|
||||
status=AgentRuntimeSessionStatus.ACTIVE,
|
||||
)
|
||||
session.add(row)
|
||||
else:
|
||||
row.backend_run_id = backend_run_id
|
||||
row.session_snapshot = snapshot_json
|
||||
row.status = AgentRuntimeSessionStatus.ACTIVE
|
||||
row.cleaned_at = None
|
||||
session.commit()
|
||||
|
||||
def mark_cleaned(self, *, scope: AgentAppSessionScope, backend_run_id: str | None = None) -> None:
|
||||
with session_factory.create_session() as session:
|
||||
row = session.scalar(self._active_stmt(scope))
|
||||
if row is None:
|
||||
return
|
||||
if backend_run_id is not None:
|
||||
row.backend_run_id = backend_run_id
|
||||
row.status = AgentRuntimeSessionStatus.CLEANED
|
||||
row.cleaned_at = naive_utc_now()
|
||||
session.commit()
|
||||
|
||||
@staticmethod
|
||||
def _scope_stmt(scope: AgentAppSessionScope):
|
||||
return select(AgentRuntimeSession).where(
|
||||
AgentRuntimeSession.owner_type == AgentRuntimeSessionOwnerType.CONVERSATION,
|
||||
AgentRuntimeSession.tenant_id == scope.tenant_id,
|
||||
AgentRuntimeSession.conversation_id == scope.conversation_id,
|
||||
AgentRuntimeSession.agent_id == scope.agent_id,
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def _active_stmt(cls, scope: AgentAppSessionScope):
|
||||
return cls._scope_stmt(scope).where(AgentRuntimeSession.status == AgentRuntimeSessionStatus.ACTIVE)
|
||||
|
||||
|
||||
__all__ = ["AgentAppRuntimeSessionStore", "AgentAppSessionScope"]
|
||||
@ -200,6 +200,19 @@ class AgentChatAppGenerateEntity(ConversationAppGenerateEntity, EasyUIBasedAppGe
|
||||
pass
|
||||
|
||||
|
||||
class AgentAppGenerateEntity(ConversationAppGenerateEntity, EasyUIBasedAppGenerateEntity):
|
||||
"""
|
||||
Agent App (new Agent app type) Generate Entity.
|
||||
|
||||
Rides the EasyUI chat pipeline, but the answer is produced by the dify-agent
|
||||
backend rather than an in-process LLM call. ``model_conf`` is synthesized
|
||||
from the bound Agent Soul model so the chat task pipeline can persist usage.
|
||||
"""
|
||||
|
||||
agent_id: str
|
||||
agent_config_snapshot_id: str
|
||||
|
||||
|
||||
class AdvancedChatAppGenerateEntity(ConversationAppGenerateEntity):
|
||||
"""
|
||||
Advanced Chat Application Generate Entity.
|
||||
|
||||
@ -10,6 +10,7 @@ from clients.agent_backend.request_builder import CleanupLayerSpec
|
||||
from core.db.session_factory import session_factory
|
||||
from libs.datetime_utils import naive_utc_now
|
||||
from models.agent import (
|
||||
AgentRuntimeSessionOwnerType,
|
||||
WorkflowAgentRuntimeSession,
|
||||
WorkflowAgentRuntimeSessionStatus,
|
||||
)
|
||||
@ -125,6 +126,7 @@ class WorkflowAgentRuntimeSessionStore:
|
||||
row = WorkflowAgentRuntimeSession(
|
||||
tenant_id=scope.tenant_id,
|
||||
app_id=scope.app_id,
|
||||
owner_type=AgentRuntimeSessionOwnerType.WORKFLOW_RUN,
|
||||
workflow_id=scope.workflow_id,
|
||||
workflow_run_id=scope.workflow_run_id,
|
||||
node_id=scope.node_id,
|
||||
|
||||
192
api/fields/agent_fields.py
Normal file
192
api/fields/agent_fields.py
Normal file
@ -0,0 +1,192 @@
|
||||
from typing import Any, Literal
|
||||
|
||||
from pydantic import Field
|
||||
|
||||
from fields.base import ResponseModel
|
||||
from models.agent import (
|
||||
AgentConfigRevisionOperation,
|
||||
AgentIconType,
|
||||
AgentKind,
|
||||
AgentScope,
|
||||
AgentSource,
|
||||
AgentStatus,
|
||||
WorkflowAgentBindingType,
|
||||
)
|
||||
from models.agent_config_entities import (
|
||||
AgentSoulConfig,
|
||||
DeclaredOutputConfig,
|
||||
DeclaredOutputType,
|
||||
WorkflowNodeJobConfig,
|
||||
)
|
||||
from services.entities.agent_entities import (
|
||||
ComposerCandidateCapabilities,
|
||||
ComposerSaveStrategy,
|
||||
ComposerVariant,
|
||||
)
|
||||
|
||||
|
||||
class AgentConfigSnapshotSummaryResponse(ResponseModel):
|
||||
id: str
|
||||
agent_id: str | None = None
|
||||
version: int
|
||||
summary: str | None = None
|
||||
version_note: str | None = None
|
||||
created_by: str | None = None
|
||||
created_at: str | None = None
|
||||
|
||||
|
||||
class AgentRosterResponse(ResponseModel):
|
||||
id: str
|
||||
name: str
|
||||
description: str
|
||||
icon_type: AgentIconType | None = None
|
||||
icon: str | None = None
|
||||
icon_background: str | None = None
|
||||
agent_kind: AgentKind
|
||||
scope: AgentScope
|
||||
source: AgentSource
|
||||
app_id: str | None = None
|
||||
workflow_id: str | None = None
|
||||
workflow_node_id: str | None = None
|
||||
active_config_snapshot_id: str | None = None
|
||||
active_config_snapshot: AgentConfigSnapshotSummaryResponse | None = None
|
||||
status: AgentStatus
|
||||
created_by: str | None = None
|
||||
updated_by: str | None = None
|
||||
archived_by: str | None = None
|
||||
archived_at: str | None = None
|
||||
created_at: str | None = None
|
||||
updated_at: str | None = None
|
||||
|
||||
|
||||
class AgentInviteOptionResponse(AgentRosterResponse):
|
||||
is_in_current_workflow: bool = False
|
||||
in_current_workflow_count: int = 0
|
||||
existing_node_ids: list[str] = Field(default_factory=list)
|
||||
|
||||
|
||||
class AgentRosterListResponse(ResponseModel):
|
||||
data: list[AgentRosterResponse]
|
||||
page: int
|
||||
limit: int
|
||||
total: int
|
||||
has_more: bool
|
||||
|
||||
|
||||
class AgentInviteOptionsResponse(ResponseModel):
|
||||
data: list[AgentInviteOptionResponse]
|
||||
page: int
|
||||
limit: int
|
||||
total: int
|
||||
has_more: bool
|
||||
|
||||
|
||||
class AgentConfigRevisionResponse(ResponseModel):
|
||||
id: str
|
||||
previous_snapshot_id: str | None = None
|
||||
current_snapshot_id: str
|
||||
revision: int
|
||||
operation: AgentConfigRevisionOperation
|
||||
summary: str | None = None
|
||||
version_note: str | None = None
|
||||
created_by: str | None = None
|
||||
created_at: str | None = None
|
||||
|
||||
|
||||
class AgentConfigSnapshotDetailResponse(AgentConfigSnapshotSummaryResponse):
|
||||
config_snapshot: AgentSoulConfig
|
||||
revisions: list[AgentConfigRevisionResponse] = Field(default_factory=list)
|
||||
|
||||
|
||||
class AgentConfigSnapshotListResponse(ResponseModel):
|
||||
data: list[AgentConfigSnapshotSummaryResponse]
|
||||
|
||||
|
||||
class AgentComposerAgentResponse(ResponseModel):
|
||||
id: str
|
||||
name: str
|
||||
description: str
|
||||
scope: AgentScope
|
||||
status: AgentStatus
|
||||
active_config_snapshot_id: str | None = None
|
||||
|
||||
|
||||
class AgentComposerBindingResponse(ResponseModel):
|
||||
id: str
|
||||
binding_type: WorkflowAgentBindingType
|
||||
agent_id: str | None = None
|
||||
current_snapshot_id: str | None = None
|
||||
workflow_id: str
|
||||
node_id: str
|
||||
|
||||
|
||||
class AgentComposerSoulLockResponse(ResponseModel):
|
||||
locked: bool
|
||||
can_unlock: bool = False
|
||||
reason: str | None = None
|
||||
|
||||
|
||||
class AgentComposerImpactBindingResponse(ResponseModel):
|
||||
app_id: str
|
||||
workflow_id: str
|
||||
node_id: str
|
||||
|
||||
|
||||
class AgentComposerImpactResponse(ResponseModel):
|
||||
current_snapshot_id: str | None = None
|
||||
workflow_node_count: int
|
||||
bindings: list[AgentComposerImpactBindingResponse] = Field(default_factory=list)
|
||||
|
||||
|
||||
class WorkflowAgentComposerResponse(ResponseModel):
|
||||
variant: Literal[ComposerVariant.WORKFLOW]
|
||||
agent: AgentComposerAgentResponse | None = None
|
||||
active_config_snapshot: AgentConfigSnapshotSummaryResponse | None = None
|
||||
binding: AgentComposerBindingResponse | None = None
|
||||
soul_lock: AgentComposerSoulLockResponse
|
||||
agent_soul: AgentSoulConfig
|
||||
node_job: WorkflowNodeJobConfig
|
||||
effective_declared_outputs: list[DeclaredOutputConfig] = Field(default_factory=list)
|
||||
save_options: list[ComposerSaveStrategy]
|
||||
impact_summary: AgentComposerImpactResponse | None = None
|
||||
app_id: str | None = None
|
||||
workflow_id: str | None = None
|
||||
node_id: str | None = None
|
||||
|
||||
|
||||
class AgentAppComposerResponse(ResponseModel):
|
||||
variant: Literal[ComposerVariant.AGENT_APP]
|
||||
agent: AgentComposerAgentResponse
|
||||
active_config_snapshot: AgentConfigSnapshotSummaryResponse
|
||||
agent_soul: AgentSoulConfig
|
||||
save_options: list[ComposerSaveStrategy]
|
||||
|
||||
|
||||
class AgentComposerValidateResponse(ResponseModel):
|
||||
result: Literal["success"]
|
||||
errors: list[str] = Field(default_factory=list)
|
||||
|
||||
|
||||
class AgentComposerNodeJobCandidatesResponse(ResponseModel):
|
||||
previous_node_outputs: list[dict[str, Any]] = Field(default_factory=list)
|
||||
declare_output_types: list[DeclaredOutputType] = Field(default_factory=list)
|
||||
human_contacts: list[dict[str, Any]] = Field(default_factory=list)
|
||||
|
||||
|
||||
class AgentComposerSoulCandidatesResponse(ResponseModel):
|
||||
skills_files: list[dict[str, Any]] = Field(default_factory=list)
|
||||
dify_tools: list[dict[str, Any]] = Field(default_factory=list)
|
||||
cli_tools: list[dict[str, Any]] = Field(default_factory=list)
|
||||
knowledge_datasets: list[dict[str, Any]] = Field(default_factory=list)
|
||||
human_contacts: list[dict[str, Any]] = Field(default_factory=list)
|
||||
|
||||
|
||||
class AgentComposerCandidatesResponse(ResponseModel):
|
||||
variant: ComposerVariant
|
||||
allowed_node_job_candidates: AgentComposerNodeJobCandidatesResponse = Field(
|
||||
default_factory=AgentComposerNodeJobCandidatesResponse
|
||||
)
|
||||
allowed_soul_candidates: AgentComposerSoulCandidatesResponse = Field(
|
||||
default_factory=AgentComposerSoulCandidatesResponse
|
||||
)
|
||||
capabilities: ComposerCandidateCapabilities = Field(default_factory=ComposerCandidateCapabilities)
|
||||
@ -0,0 +1,140 @@
|
||||
"""unify agent runtime sessions table
|
||||
|
||||
Revision ID: 121e7346074d
|
||||
Revises: 7885bd53f9a9
|
||||
Create Date: 2026-05-29 10:54:19.400054
|
||||
|
||||
Unifies the workflow-only ``workflow_agent_runtime_sessions`` table into an
|
||||
owner-agnostic ``agent_runtime_sessions`` table that serves both workflow
|
||||
Agent Node runs (owner_type=workflow_run) and Agent App conversations
|
||||
(owner_type=conversation). The feature is unreleased, so the old table is
|
||||
dropped rather than migrated (no data to preserve).
|
||||
"""
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
|
||||
import models as models
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision = "121e7346074d"
|
||||
down_revision = "7885bd53f9a9"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def _is_pg() -> bool:
|
||||
return op.get_bind().dialect.name == "postgresql"
|
||||
|
||||
|
||||
def _uuid_column(name: str, *, nullable: bool = False, primary_key: bool = False) -> sa.Column:
|
||||
kwargs: dict[str, object] = {"nullable": nullable, "primary_key": primary_key}
|
||||
if primary_key and _is_pg():
|
||||
kwargs["server_default"] = sa.text("uuidv7()")
|
||||
return sa.Column(name, models.types.StringUUID(), **kwargs)
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
# Drop the unreleased workflow-only table; recreate as the unified table.
|
||||
op.drop_table("workflow_agent_runtime_sessions")
|
||||
|
||||
op.create_table(
|
||||
"agent_runtime_sessions",
|
||||
_uuid_column("id", primary_key=True),
|
||||
sa.Column("tenant_id", models.types.StringUUID(), nullable=False),
|
||||
sa.Column("app_id", models.types.StringUUID(), nullable=False),
|
||||
sa.Column("owner_type", sa.String(length=32), nullable=False),
|
||||
sa.Column("agent_id", models.types.StringUUID(), nullable=False),
|
||||
sa.Column("backend_run_id", sa.String(length=255), nullable=True),
|
||||
sa.Column("session_snapshot", models.types.LongText(), nullable=False),
|
||||
# Workflow-owner columns (NULL for conversation owner).
|
||||
sa.Column("workflow_id", models.types.StringUUID(), nullable=True),
|
||||
sa.Column("workflow_run_id", models.types.StringUUID(), nullable=True),
|
||||
sa.Column("node_id", sa.String(length=255), nullable=True),
|
||||
sa.Column("node_execution_id", sa.String(length=255), nullable=True),
|
||||
sa.Column("binding_id", models.types.StringUUID(), nullable=True),
|
||||
sa.Column("agent_config_snapshot_id", models.types.StringUUID(), nullable=True),
|
||||
# MySQL rejects defaults on TEXT; the ORM always supplies this value.
|
||||
sa.Column("composition_layer_specs", models.types.LongText(), nullable=False),
|
||||
# Conversation-owner column (NULL for workflow owner).
|
||||
sa.Column("conversation_id", models.types.StringUUID(), nullable=True),
|
||||
sa.Column("status", sa.String(length=32), server_default=sa.text("'active'"), nullable=False),
|
||||
sa.Column("cleaned_at", sa.DateTime(), nullable=True),
|
||||
sa.Column("created_at", sa.DateTime(), server_default=sa.func.current_timestamp(), nullable=False),
|
||||
sa.Column("updated_at", sa.DateTime(), server_default=sa.func.current_timestamp(), nullable=False),
|
||||
sa.PrimaryKeyConstraint("id", name=op.f("agent_runtime_session_pkey")),
|
||||
)
|
||||
with op.batch_alter_table("agent_runtime_sessions", schema=None) as batch_op:
|
||||
batch_op.create_index(
|
||||
"agent_runtime_session_workflow_scope_unique",
|
||||
["tenant_id", "workflow_run_id", "node_id", "binding_id", "agent_id"],
|
||||
unique=True,
|
||||
postgresql_where=sa.text("workflow_run_id IS NOT NULL"),
|
||||
)
|
||||
batch_op.create_index(
|
||||
"agent_runtime_session_conversation_scope_unique",
|
||||
["tenant_id", "conversation_id", "agent_id"],
|
||||
unique=True,
|
||||
postgresql_where=sa.text("conversation_id IS NOT NULL"),
|
||||
)
|
||||
batch_op.create_index(
|
||||
"agent_runtime_session_workflow_lookup_idx",
|
||||
["tenant_id", "workflow_run_id", "node_id", "status"],
|
||||
)
|
||||
batch_op.create_index(
|
||||
"agent_runtime_session_conversation_lookup_idx",
|
||||
["tenant_id", "conversation_id", "status"],
|
||||
)
|
||||
batch_op.create_index("agent_runtime_session_backend_run_idx", ["backend_run_id"])
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
with op.batch_alter_table("agent_runtime_sessions", schema=None) as batch_op:
|
||||
batch_op.drop_index("agent_runtime_session_backend_run_idx")
|
||||
batch_op.drop_index("agent_runtime_session_conversation_lookup_idx")
|
||||
batch_op.drop_index("agent_runtime_session_workflow_lookup_idx")
|
||||
batch_op.drop_index(
|
||||
"agent_runtime_session_conversation_scope_unique",
|
||||
postgresql_where=sa.text("conversation_id IS NOT NULL"),
|
||||
)
|
||||
batch_op.drop_index(
|
||||
"agent_runtime_session_workflow_scope_unique",
|
||||
postgresql_where=sa.text("workflow_run_id IS NOT NULL"),
|
||||
)
|
||||
op.drop_table("agent_runtime_sessions")
|
||||
|
||||
op.create_table(
|
||||
"workflow_agent_runtime_sessions",
|
||||
_uuid_column("id", primary_key=True),
|
||||
sa.Column("tenant_id", models.types.StringUUID(), nullable=False),
|
||||
sa.Column("app_id", models.types.StringUUID(), nullable=False),
|
||||
sa.Column("workflow_id", models.types.StringUUID(), nullable=False),
|
||||
sa.Column("workflow_run_id", models.types.StringUUID(), nullable=False),
|
||||
sa.Column("node_id", sa.String(length=255), nullable=False),
|
||||
sa.Column("node_execution_id", sa.String(length=255), nullable=True),
|
||||
sa.Column("binding_id", models.types.StringUUID(), nullable=False),
|
||||
sa.Column("agent_id", models.types.StringUUID(), nullable=False),
|
||||
sa.Column("agent_config_snapshot_id", models.types.StringUUID(), nullable=False),
|
||||
sa.Column("backend_run_id", sa.String(length=255), nullable=True),
|
||||
sa.Column("session_snapshot", models.types.LongText(), nullable=False),
|
||||
sa.Column("composition_layer_specs", models.types.LongText(), nullable=False),
|
||||
sa.Column("status", sa.String(length=32), server_default=sa.text("'active'"), nullable=False),
|
||||
sa.Column("cleaned_at", sa.DateTime(), nullable=True),
|
||||
sa.Column("created_at", sa.DateTime(), server_default=sa.func.current_timestamp(), nullable=False),
|
||||
sa.Column("updated_at", sa.DateTime(), server_default=sa.func.current_timestamp(), nullable=False),
|
||||
sa.PrimaryKeyConstraint("id", name=op.f("workflow_agent_runtime_session_pkey")),
|
||||
sa.UniqueConstraint(
|
||||
"tenant_id",
|
||||
"workflow_run_id",
|
||||
"node_id",
|
||||
"binding_id",
|
||||
"agent_id",
|
||||
name=op.f("workflow_agent_runtime_session_scope_unique"),
|
||||
),
|
||||
)
|
||||
with op.batch_alter_table("workflow_agent_runtime_sessions", schema=None) as batch_op:
|
||||
batch_op.create_index(
|
||||
"workflow_agent_runtime_session_lookup_idx",
|
||||
["tenant_id", "workflow_run_id", "node_id", "status"],
|
||||
)
|
||||
batch_op.create_index("workflow_agent_runtime_session_backend_run_idx", ["backend_run_id"])
|
||||
@ -15,6 +15,9 @@ from .agent import (
|
||||
AgentConfigSnapshot,
|
||||
AgentIconType,
|
||||
AgentKind,
|
||||
AgentRuntimeSession,
|
||||
AgentRuntimeSessionOwnerType,
|
||||
AgentRuntimeSessionStatus,
|
||||
AgentScope,
|
||||
AgentSource,
|
||||
AgentStatus,
|
||||
@ -146,6 +149,9 @@ __all__ = [
|
||||
"AgentConfigSnapshot",
|
||||
"AgentIconType",
|
||||
"AgentKind",
|
||||
"AgentRuntimeSession",
|
||||
"AgentRuntimeSessionOwnerType",
|
||||
"AgentRuntimeSessionStatus",
|
||||
"AgentScope",
|
||||
"AgentSource",
|
||||
"AgentStatus",
|
||||
|
||||
@ -92,15 +92,33 @@ class WorkflowAgentBindingType(StrEnum):
|
||||
INLINE_AGENT = "inline_agent"
|
||||
|
||||
|
||||
class WorkflowAgentRuntimeSessionStatus(StrEnum):
|
||||
"""Lifecycle state of an Agent backend session snapshot owned by a workflow run."""
|
||||
class AgentRuntimeSessionStatus(StrEnum):
|
||||
"""Lifecycle state of an Agent backend session snapshot.
|
||||
|
||||
# Snapshot can be reused by a later Agent run in the same workflow run.
|
||||
Owner-agnostic: applies both to workflow Agent Node runs (owner =
|
||||
workflow_run) and to Agent App conversations (owner = conversation).
|
||||
"""
|
||||
|
||||
# Snapshot can be reused by a later Agent run in the same session.
|
||||
ACTIVE = "active"
|
||||
# Snapshot has been retired and must not be submitted to Agent backend again.
|
||||
CLEANED = "cleaned"
|
||||
|
||||
|
||||
class AgentRuntimeSessionOwnerType(StrEnum):
|
||||
"""Which product surface owns an Agent runtime session row."""
|
||||
|
||||
# Owned by one workflow Agent Node execution scope.
|
||||
WORKFLOW_RUN = "workflow_run"
|
||||
# Owned by one Agent App conversation (multi-turn chat).
|
||||
CONVERSATION = "conversation"
|
||||
|
||||
|
||||
# Back-compat alias: the workflow lifecycle code (shipped in PR #36724) imports
|
||||
# the old name. Kept so unifying the table does not churn that path.
|
||||
WorkflowAgentRuntimeSessionStatus = AgentRuntimeSessionStatus
|
||||
|
||||
|
||||
class Agent(DefaultFieldsMixin, Base):
|
||||
"""Workspace-scoped Agent identity used by Agent Roster and workflow-only agents."""
|
||||
|
||||
@ -284,54 +302,88 @@ class WorkflowAgentNodeBinding(DefaultFieldsMixin, Base):
|
||||
return dict(self.node_job_config)
|
||||
|
||||
|
||||
class WorkflowAgentRuntimeSession(DefaultFieldsMixin, Base):
|
||||
"""Persisted Agent backend session snapshot for one workflow Agent node execution scope.
|
||||
class AgentRuntimeSession(DefaultFieldsMixin, Base):
|
||||
"""Persisted Agent backend session snapshot, owner-agnostic.
|
||||
|
||||
The snapshot is runtime state returned by Agent backend. It is intentionally
|
||||
separate from Agent Soul snapshots and workflow node-job config.
|
||||
One unified table serves both owners (decision Q2):
|
||||
- workflow Agent Node runs: ``owner_type = workflow_run``; the
|
||||
``workflow_id / workflow_run_id / node_id / binding_id /
|
||||
agent_config_snapshot_id / composition_layer_specs`` columns are set.
|
||||
- Agent App conversations: ``owner_type = conversation``; the
|
||||
``conversation_id`` column is set and the workflow columns stay NULL.
|
||||
|
||||
The snapshot is runtime state returned by Agent backend, kept separate from
|
||||
Agent Soul snapshots and workflow node-job config.
|
||||
"""
|
||||
|
||||
__tablename__ = "workflow_agent_runtime_sessions"
|
||||
__tablename__ = "agent_runtime_sessions"
|
||||
__table_args__ = (
|
||||
sa.PrimaryKeyConstraint("id", name="workflow_agent_runtime_session_pkey"),
|
||||
UniqueConstraint(
|
||||
sa.PrimaryKeyConstraint("id", name="agent_runtime_session_pkey"),
|
||||
# Workflow owner uniqueness (partial: only rows with a workflow_run_id).
|
||||
Index(
|
||||
"agent_runtime_session_workflow_scope_unique",
|
||||
"tenant_id",
|
||||
"workflow_run_id",
|
||||
"node_id",
|
||||
"binding_id",
|
||||
"agent_id",
|
||||
name="workflow_agent_runtime_session_scope_unique",
|
||||
unique=True,
|
||||
postgresql_where=sa.text("workflow_run_id IS NOT NULL"),
|
||||
),
|
||||
# Conversation owner uniqueness (partial: only rows with a conversation_id).
|
||||
Index(
|
||||
"agent_runtime_session_conversation_scope_unique",
|
||||
"tenant_id",
|
||||
"conversation_id",
|
||||
"agent_id",
|
||||
unique=True,
|
||||
postgresql_where=sa.text("conversation_id IS NOT NULL"),
|
||||
),
|
||||
Index(
|
||||
"workflow_agent_runtime_session_lookup_idx",
|
||||
"agent_runtime_session_workflow_lookup_idx",
|
||||
"tenant_id",
|
||||
"workflow_run_id",
|
||||
"node_id",
|
||||
"status",
|
||||
),
|
||||
Index("workflow_agent_runtime_session_backend_run_idx", "backend_run_id"),
|
||||
Index(
|
||||
"agent_runtime_session_conversation_lookup_idx",
|
||||
"tenant_id",
|
||||
"conversation_id",
|
||||
"status",
|
||||
),
|
||||
Index("agent_runtime_session_backend_run_idx", "backend_run_id"),
|
||||
)
|
||||
|
||||
tenant_id: Mapped[str] = mapped_column(StringUUID, nullable=False)
|
||||
app_id: Mapped[str] = mapped_column(StringUUID, nullable=False)
|
||||
workflow_id: Mapped[str] = mapped_column(StringUUID, nullable=False)
|
||||
workflow_run_id: Mapped[str] = mapped_column(StringUUID, nullable=False)
|
||||
node_id: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||
node_execution_id: Mapped[str | None] = mapped_column(String(255), nullable=True)
|
||||
binding_id: Mapped[str] = mapped_column(StringUUID, nullable=False)
|
||||
owner_type: Mapped[AgentRuntimeSessionOwnerType] = mapped_column(
|
||||
EnumText(AgentRuntimeSessionOwnerType, length=32), nullable=False
|
||||
)
|
||||
agent_id: Mapped[str] = mapped_column(StringUUID, nullable=False)
|
||||
agent_config_snapshot_id: Mapped[str] = mapped_column(StringUUID, nullable=False)
|
||||
backend_run_id: Mapped[str | None] = mapped_column(String(255), nullable=True)
|
||||
session_snapshot: Mapped[str] = mapped_column(LongText, nullable=False)
|
||||
# JSON-encoded list of ``WorkflowAgentSessionLayerSpec`` ({name, type, deps,
|
||||
# config}). Drives Agent backend cleanup-only runs: the agenton compositor
|
||||
# rejects a session snapshot whose layer names do not match the cleanup
|
||||
# composition, so we must replay the same layer graph (minus credential-
|
||||
# bearing plugin layers) when issuing the cleanup request.
|
||||
# Workflow-owner columns (NULL for conversation owner).
|
||||
workflow_id: Mapped[str | None] = mapped_column(StringUUID, nullable=True)
|
||||
workflow_run_id: Mapped[str | None] = mapped_column(StringUUID, nullable=True)
|
||||
node_id: Mapped[str | None] = mapped_column(String(255), nullable=True)
|
||||
node_execution_id: Mapped[str | None] = mapped_column(String(255), nullable=True)
|
||||
binding_id: Mapped[str | None] = mapped_column(StringUUID, nullable=True)
|
||||
agent_config_snapshot_id: Mapped[str | None] = mapped_column(StringUUID, nullable=True)
|
||||
# JSON-encoded list of cleanup layer specs ({name, type, deps, config}).
|
||||
# Drives Agent backend cleanup-only runs: the agenton compositor rejects a
|
||||
# session snapshot whose layer names do not match the cleanup composition,
|
||||
# so we replay the same layer graph (minus credential-bearing plugin layers).
|
||||
composition_layer_specs: Mapped[str] = mapped_column(LongText, nullable=False, server_default="[]")
|
||||
status: Mapped[WorkflowAgentRuntimeSessionStatus] = mapped_column(
|
||||
EnumText(WorkflowAgentRuntimeSessionStatus, length=32),
|
||||
# Conversation-owner column (NULL for workflow owner).
|
||||
conversation_id: Mapped[str | None] = mapped_column(StringUUID, nullable=True)
|
||||
status: Mapped[AgentRuntimeSessionStatus] = mapped_column(
|
||||
EnumText(AgentRuntimeSessionStatus, length=32),
|
||||
nullable=False,
|
||||
default=WorkflowAgentRuntimeSessionStatus.ACTIVE,
|
||||
default=AgentRuntimeSessionStatus.ACTIVE,
|
||||
)
|
||||
cleaned_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
|
||||
|
||||
|
||||
# Back-compat alias for the shipped workflow lifecycle code (PR #36724).
|
||||
WorkflowAgentRuntimeSession = AgentRuntimeSession
|
||||
|
||||
@ -366,6 +366,10 @@ class AppMode(StrEnum):
|
||||
CHAT = "chat"
|
||||
ADVANCED_CHAT = "advanced-chat"
|
||||
AGENT_CHAT = "agent-chat"
|
||||
# New Agent App type backed by the Dify Agent runtime (distinct from the
|
||||
# legacy ``agent-chat`` ReAct app). The app is bound 1:1 to a roster Agent
|
||||
# via ``Agent.app_id``; its configuration lives in the Agent Soul snapshot.
|
||||
AGENT = "agent"
|
||||
CHANNEL = "channel"
|
||||
RAG_PIPELINE = "rag-pipeline"
|
||||
|
||||
@ -458,6 +462,27 @@ class App(Base):
|
||||
|
||||
return None
|
||||
|
||||
@property
|
||||
def bound_agent_id(self) -> str | None:
|
||||
"""For an Agent App (mode=agent), the roster Agent it is backed by.
|
||||
|
||||
Resolved via ``Agent.app_id`` so the console can open the Composer in
|
||||
roster-detail mode from the app id. ``None`` for non-agent apps.
|
||||
"""
|
||||
if self.mode != AppMode.AGENT:
|
||||
return None
|
||||
from .agent import Agent, AgentScope, AgentSource, AgentStatus
|
||||
|
||||
agent = db.session.scalar(
|
||||
select(Agent).where(
|
||||
Agent.app_id == self.id,
|
||||
Agent.scope == AgentScope.ROSTER,
|
||||
Agent.source == AgentSource.AGENT_APP,
|
||||
Agent.status == AgentStatus.ACTIVE,
|
||||
)
|
||||
)
|
||||
return agent.id if agent else None
|
||||
|
||||
@property
|
||||
def api_base_url(self) -> str:
|
||||
base = dify_config.SERVICE_API_URL or request.host_url.rstrip("/")
|
||||
|
||||
@ -343,11 +343,19 @@ Check if activation token is valid
|
||||
### /agents
|
||||
|
||||
#### GET
|
||||
##### Parameters
|
||||
|
||||
| Name | Located in | Description | Required | Schema |
|
||||
| ---- | ---------- | ----------- | -------- | ------ |
|
||||
| keyword | query | | No | string |
|
||||
| limit | query | | No | integer |
|
||||
| page | query | | No | integer |
|
||||
|
||||
##### Responses
|
||||
|
||||
| Code | Description |
|
||||
| ---- | ----------- |
|
||||
| 200 | Success |
|
||||
| Code | Description | Schema |
|
||||
| ---- | ----------- | ------ |
|
||||
| 200 | Agent roster list | [AgentRosterListResponse](#agentrosterlistresponse) |
|
||||
|
||||
#### POST
|
||||
##### Parameters
|
||||
@ -358,18 +366,27 @@ Check if activation token is valid
|
||||
|
||||
##### Responses
|
||||
|
||||
| Code | Description |
|
||||
| ---- | ----------- |
|
||||
| 200 | Success |
|
||||
| Code | Description | Schema |
|
||||
| ---- | ----------- | ------ |
|
||||
| 201 | Agent created | [AgentRosterResponse](#agentrosterresponse) |
|
||||
|
||||
### /agents/invite-options
|
||||
|
||||
#### GET
|
||||
##### Parameters
|
||||
|
||||
| Name | Located in | Description | Required | Schema |
|
||||
| ---- | ---------- | ----------- | -------- | ------ |
|
||||
| app_id | query | Workflow app id for in-current-workflow markers | No | string |
|
||||
| keyword | query | | No | string |
|
||||
| limit | query | | No | integer |
|
||||
| page | query | | No | integer |
|
||||
|
||||
##### Responses
|
||||
|
||||
| Code | Description |
|
||||
| ---- | ----------- |
|
||||
| 200 | Success |
|
||||
| Code | Description | Schema |
|
||||
| ---- | ----------- | ------ |
|
||||
| 200 | Agent invite options | [AgentInviteOptionsResponse](#agentinviteoptionsresponse) |
|
||||
|
||||
### /agents/{agent_id}
|
||||
|
||||
@ -384,7 +401,7 @@ Check if activation token is valid
|
||||
|
||||
| Code | Description |
|
||||
| ---- | ----------- |
|
||||
| 200 | Success |
|
||||
| 204 | Agent archived |
|
||||
|
||||
#### GET
|
||||
##### Parameters
|
||||
@ -395,9 +412,9 @@ Check if activation token is valid
|
||||
|
||||
##### Responses
|
||||
|
||||
| Code | Description |
|
||||
| ---- | ----------- |
|
||||
| 200 | Success |
|
||||
| Code | Description | Schema |
|
||||
| ---- | ----------- | ------ |
|
||||
| 200 | Agent detail | [AgentRosterResponse](#agentrosterresponse) |
|
||||
|
||||
#### PATCH
|
||||
##### Parameters
|
||||
@ -409,9 +426,9 @@ Check if activation token is valid
|
||||
|
||||
##### Responses
|
||||
|
||||
| Code | Description |
|
||||
| ---- | ----------- |
|
||||
| 200 | Success |
|
||||
| Code | Description | Schema |
|
||||
| ---- | ----------- | ------ |
|
||||
| 200 | Agent updated | [AgentRosterResponse](#agentrosterresponse) |
|
||||
|
||||
### /agents/{agent_id}/versions
|
||||
|
||||
@ -424,9 +441,9 @@ Check if activation token is valid
|
||||
|
||||
##### Responses
|
||||
|
||||
| Code | Description |
|
||||
| ---- | ----------- |
|
||||
| 200 | Success |
|
||||
| Code | Description | Schema |
|
||||
| ---- | ----------- | ------ |
|
||||
| 200 | Agent versions | [AgentConfigSnapshotListResponse](#agentconfigsnapshotlistresponse) |
|
||||
|
||||
### /agents/{agent_id}/versions/{version_id}
|
||||
|
||||
@ -440,9 +457,9 @@ Check if activation token is valid
|
||||
|
||||
##### Responses
|
||||
|
||||
| Code | Description |
|
||||
| ---- | ----------- |
|
||||
| 200 | Success |
|
||||
| Code | Description | Schema |
|
||||
| ---- | ----------- | ------ |
|
||||
| 200 | Agent version detail | [AgentConfigSnapshotDetailResponse](#agentconfigsnapshotdetailresponse) |
|
||||
|
||||
### /all-workspaces
|
||||
|
||||
@ -978,9 +995,9 @@ Run draft workflow for advanced chat application
|
||||
|
||||
##### Responses
|
||||
|
||||
| Code | Description |
|
||||
| ---- | ----------- |
|
||||
| 200 | Success |
|
||||
| Code | Description | Schema |
|
||||
| ---- | ----------- | ------ |
|
||||
| 200 | Agent app composer state | [AgentAppComposerResponse](#agentappcomposerresponse) |
|
||||
|
||||
#### PUT
|
||||
##### Parameters
|
||||
@ -992,9 +1009,9 @@ Run draft workflow for advanced chat application
|
||||
|
||||
##### Responses
|
||||
|
||||
| Code | Description |
|
||||
| ---- | ----------- |
|
||||
| 200 | Success |
|
||||
| Code | Description | Schema |
|
||||
| ---- | ----------- | ------ |
|
||||
| 200 | Agent app composer saved | [AgentAppComposerResponse](#agentappcomposerresponse) |
|
||||
|
||||
### /apps/{app_id}/agent-composer/candidates
|
||||
|
||||
@ -1007,9 +1024,9 @@ Run draft workflow for advanced chat application
|
||||
|
||||
##### Responses
|
||||
|
||||
| Code | Description |
|
||||
| ---- | ----------- |
|
||||
| 200 | Success |
|
||||
| Code | Description | Schema |
|
||||
| ---- | ----------- | ------ |
|
||||
| 200 | Agent app composer candidates | [AgentComposerCandidatesResponse](#agentcomposercandidatesresponse) |
|
||||
|
||||
### /apps/{app_id}/agent-composer/validate
|
||||
|
||||
@ -1023,9 +1040,9 @@ Run draft workflow for advanced chat application
|
||||
|
||||
##### Responses
|
||||
|
||||
| Code | Description |
|
||||
| ---- | ----------- |
|
||||
| 200 | Success |
|
||||
| Code | Description | Schema |
|
||||
| ---- | ----------- | ------ |
|
||||
| 200 | Agent app composer validation result | [AgentComposerValidateResponse](#agentcomposervalidateresponse) |
|
||||
|
||||
### /apps/{app_id}/agent/logs
|
||||
|
||||
@ -3224,9 +3241,9 @@ Run draft workflow loop node
|
||||
|
||||
##### Responses
|
||||
|
||||
| Code | Description |
|
||||
| ---- | ----------- |
|
||||
| 200 | Success |
|
||||
| Code | Description | Schema |
|
||||
| ---- | ----------- | ------ |
|
||||
| 200 | Workflow agent composer state | [WorkflowAgentComposerResponse](#workflowagentcomposerresponse) |
|
||||
|
||||
#### PUT
|
||||
##### Parameters
|
||||
@ -3239,9 +3256,9 @@ Run draft workflow loop node
|
||||
|
||||
##### Responses
|
||||
|
||||
| Code | Description |
|
||||
| ---- | ----------- |
|
||||
| 200 | Success |
|
||||
| Code | Description | Schema |
|
||||
| ---- | ----------- | ------ |
|
||||
| 200 | Workflow agent composer saved | [WorkflowAgentComposerResponse](#workflowagentcomposerresponse) |
|
||||
|
||||
### /apps/{app_id}/workflows/draft/nodes/{node_id}/agent-composer/candidates
|
||||
|
||||
@ -3255,9 +3272,9 @@ Run draft workflow loop node
|
||||
|
||||
##### Responses
|
||||
|
||||
| Code | Description |
|
||||
| ---- | ----------- |
|
||||
| 200 | Success |
|
||||
| Code | Description | Schema |
|
||||
| ---- | ----------- | ------ |
|
||||
| 200 | Workflow agent composer candidates | [AgentComposerCandidatesResponse](#agentcomposercandidatesresponse) |
|
||||
|
||||
### /apps/{app_id}/workflows/draft/nodes/{node_id}/agent-composer/impact
|
||||
|
||||
@ -3268,12 +3285,13 @@ Run draft workflow loop node
|
||||
| ---- | ---------- | ----------- | -------- | ------ |
|
||||
| app_id | path | | Yes | string |
|
||||
| node_id | path | | Yes | string |
|
||||
| payload | body | | Yes | [ComposerSavePayload](#composersavepayload) |
|
||||
|
||||
##### Responses
|
||||
|
||||
| Code | Description |
|
||||
| ---- | ----------- |
|
||||
| 200 | Success |
|
||||
| Code | Description | Schema |
|
||||
| ---- | ----------- | ------ |
|
||||
| 200 | Workflow agent composer impact | [AgentComposerImpactResponse](#agentcomposerimpactresponse) |
|
||||
|
||||
### /apps/{app_id}/workflows/draft/nodes/{node_id}/agent-composer/save-to-roster
|
||||
|
||||
@ -3288,9 +3306,9 @@ Run draft workflow loop node
|
||||
|
||||
##### Responses
|
||||
|
||||
| Code | Description |
|
||||
| ---- | ----------- |
|
||||
| 200 | Success |
|
||||
| Code | Description | Schema |
|
||||
| ---- | ----------- | ------ |
|
||||
| 200 | Workflow agent composer saved to roster | [WorkflowAgentComposerResponse](#workflowagentcomposerresponse) |
|
||||
|
||||
### /apps/{app_id}/workflows/draft/nodes/{node_id}/agent-composer/validate
|
||||
|
||||
@ -3305,9 +3323,9 @@ Run draft workflow loop node
|
||||
|
||||
##### Responses
|
||||
|
||||
| Code | Description |
|
||||
| ---- | ----------- |
|
||||
| 200 | Success |
|
||||
| Code | Description | Schema |
|
||||
| ---- | ----------- | ------ |
|
||||
| 200 | Workflow agent composer validation result | [AgentComposerValidateResponse](#agentcomposervalidateresponse) |
|
||||
|
||||
### /apps/{app_id}/workflows/draft/nodes/{node_id}/last-run
|
||||
|
||||
@ -10651,6 +10669,150 @@ Get banner list
|
||||
| model_mode | string | Model mode | Yes |
|
||||
| model_name | string | Model name | Yes |
|
||||
|
||||
#### AgentAppComposerResponse
|
||||
|
||||
| Name | Type | Description | Required |
|
||||
| ---- | ---- | ----------- | -------- |
|
||||
| active_config_snapshot | [AgentConfigSnapshotSummaryResponse](#agentconfigsnapshotsummaryresponse) | | Yes |
|
||||
| agent | [AgentComposerAgentResponse](#agentcomposeragentresponse) | | Yes |
|
||||
| agent_soul | [AgentSoulConfig](#agentsoulconfig) | | Yes |
|
||||
| save_options | [ [ComposerSaveStrategy](#composersavestrategy) ] | | Yes |
|
||||
| variant | string | | Yes |
|
||||
|
||||
#### AgentComposerAgentResponse
|
||||
|
||||
| Name | Type | Description | Required |
|
||||
| ---- | ---- | ----------- | -------- |
|
||||
| active_config_snapshot_id | string | | No |
|
||||
| description | string | | Yes |
|
||||
| id | string | | Yes |
|
||||
| name | string | | Yes |
|
||||
| scope | [AgentScope](#agentscope) | | Yes |
|
||||
| status | [AgentStatus](#agentstatus) | | Yes |
|
||||
|
||||
#### AgentComposerBindingResponse
|
||||
|
||||
| Name | Type | Description | Required |
|
||||
| ---- | ---- | ----------- | -------- |
|
||||
| agent_id | string | | No |
|
||||
| binding_type | [WorkflowAgentBindingType](#workflowagentbindingtype) | | Yes |
|
||||
| current_snapshot_id | string | | No |
|
||||
| id | string | | Yes |
|
||||
| node_id | string | | Yes |
|
||||
| workflow_id | string | | Yes |
|
||||
|
||||
#### AgentComposerCandidatesResponse
|
||||
|
||||
| Name | Type | Description | Required |
|
||||
| ---- | ---- | ----------- | -------- |
|
||||
| allowed_node_job_candidates | [AgentComposerNodeJobCandidatesResponse](#agentcomposernodejobcandidatesresponse) | | No |
|
||||
| allowed_soul_candidates | [AgentComposerSoulCandidatesResponse](#agentcomposersoulcandidatesresponse) | | No |
|
||||
| capabilities | [ComposerCandidateCapabilities](#composercandidatecapabilities) | | No |
|
||||
| variant | [ComposerVariant](#composervariant) | | Yes |
|
||||
|
||||
#### AgentComposerImpactBindingResponse
|
||||
|
||||
| Name | Type | Description | Required |
|
||||
| ---- | ---- | ----------- | -------- |
|
||||
| app_id | string | | Yes |
|
||||
| node_id | string | | Yes |
|
||||
| workflow_id | string | | Yes |
|
||||
|
||||
#### AgentComposerImpactResponse
|
||||
|
||||
| Name | Type | Description | Required |
|
||||
| ---- | ---- | ----------- | -------- |
|
||||
| bindings | [ [AgentComposerImpactBindingResponse](#agentcomposerimpactbindingresponse) ] | | No |
|
||||
| current_snapshot_id | string | | No |
|
||||
| workflow_node_count | integer | | Yes |
|
||||
|
||||
#### AgentComposerNodeJobCandidatesResponse
|
||||
|
||||
| Name | Type | Description | Required |
|
||||
| ---- | ---- | ----------- | -------- |
|
||||
| declare_output_types | [ [DeclaredOutputType](#declaredoutputtype) ] | | No |
|
||||
| human_contacts | [ object ] | | No |
|
||||
| previous_node_outputs | [ object ] | | No |
|
||||
|
||||
#### AgentComposerSoulCandidatesResponse
|
||||
|
||||
| Name | Type | Description | Required |
|
||||
| ---- | ---- | ----------- | -------- |
|
||||
| cli_tools | [ object ] | | No |
|
||||
| dify_tools | [ object ] | | No |
|
||||
| human_contacts | [ object ] | | No |
|
||||
| knowledge_datasets | [ object ] | | No |
|
||||
| skills_files | [ object ] | | No |
|
||||
|
||||
#### AgentComposerSoulLockResponse
|
||||
|
||||
| Name | Type | Description | Required |
|
||||
| ---- | ---- | ----------- | -------- |
|
||||
| can_unlock | boolean | | No |
|
||||
| locked | boolean | | Yes |
|
||||
| reason | string | | No |
|
||||
|
||||
#### AgentComposerValidateResponse
|
||||
|
||||
| Name | Type | Description | Required |
|
||||
| ---- | ---- | ----------- | -------- |
|
||||
| errors | [ string ] | | No |
|
||||
| result | string | | Yes |
|
||||
|
||||
#### AgentConfigRevisionOperation
|
||||
|
||||
Audit operation recorded for Agent Soul version/revision changes.
|
||||
|
||||
| Name | Type | Description | Required |
|
||||
| ---- | ---- | ----------- | -------- |
|
||||
| AgentConfigRevisionOperation | string | Audit operation recorded for Agent Soul version/revision changes. | |
|
||||
|
||||
#### AgentConfigRevisionResponse
|
||||
|
||||
| Name | Type | Description | Required |
|
||||
| ---- | ---- | ----------- | -------- |
|
||||
| created_at | string | | No |
|
||||
| created_by | string | | No |
|
||||
| current_snapshot_id | string | | Yes |
|
||||
| id | string | | Yes |
|
||||
| operation | [AgentConfigRevisionOperation](#agentconfigrevisionoperation) | | Yes |
|
||||
| previous_snapshot_id | string | | No |
|
||||
| revision | integer | | Yes |
|
||||
| summary | string | | No |
|
||||
| version_note | string | | No |
|
||||
|
||||
#### AgentConfigSnapshotDetailResponse
|
||||
|
||||
| Name | Type | Description | Required |
|
||||
| ---- | ---- | ----------- | -------- |
|
||||
| agent_id | string | | No |
|
||||
| config_snapshot | [AgentSoulConfig](#agentsoulconfig) | | Yes |
|
||||
| created_at | string | | No |
|
||||
| created_by | string | | No |
|
||||
| id | string | | Yes |
|
||||
| revisions | [ [AgentConfigRevisionResponse](#agentconfigrevisionresponse) ] | | No |
|
||||
| summary | string | | No |
|
||||
| version | integer | | Yes |
|
||||
| version_note | string | | No |
|
||||
|
||||
#### AgentConfigSnapshotListResponse
|
||||
|
||||
| Name | Type | Description | Required |
|
||||
| ---- | ---- | ----------- | -------- |
|
||||
| data | [ [AgentConfigSnapshotSummaryResponse](#agentconfigsnapshotsummaryresponse) ] | | Yes |
|
||||
|
||||
#### AgentConfigSnapshotSummaryResponse
|
||||
|
||||
| Name | Type | Description | Required |
|
||||
| ---- | ---- | ----------- | -------- |
|
||||
| agent_id | string | | No |
|
||||
| created_at | string | | No |
|
||||
| created_by | string | | No |
|
||||
| id | string | | Yes |
|
||||
| summary | string | | No |
|
||||
| version | integer | | Yes |
|
||||
| version_note | string | | No |
|
||||
|
||||
#### AgentIconType
|
||||
|
||||
Supported icon storage formats for Agent roster entries.
|
||||
@ -10665,6 +10827,35 @@ Supported icon storage formats for Agent roster entries.
|
||||
| ---- | ---- | ----------- | -------- |
|
||||
| agent_id | string | | Yes |
|
||||
|
||||
#### AgentInviteOptionResponse
|
||||
|
||||
| Name | Type | Description | Required |
|
||||
| ---- | ---- | ----------- | -------- |
|
||||
| active_config_snapshot | [AgentConfigSnapshotSummaryResponse](#agentconfigsnapshotsummaryresponse) | | No |
|
||||
| active_config_snapshot_id | string | | No |
|
||||
| agent_kind | [AgentKind](#agentkind) | | Yes |
|
||||
| app_id | string | | No |
|
||||
| archived_at | string | | No |
|
||||
| archived_by | string | | No |
|
||||
| created_at | string | | No |
|
||||
| created_by | string | | No |
|
||||
| description | string | | Yes |
|
||||
| existing_node_ids | [ string ] | | No |
|
||||
| icon | string | | No |
|
||||
| icon_background | string | | No |
|
||||
| icon_type | [AgentIconType](#agenticontype) | | No |
|
||||
| id | string | | Yes |
|
||||
| in_current_workflow_count | integer | | No |
|
||||
| is_in_current_workflow | boolean | | No |
|
||||
| name | string | | Yes |
|
||||
| scope | [AgentScope](#agentscope) | | Yes |
|
||||
| source | [AgentSource](#agentsource) | | Yes |
|
||||
| status | [AgentStatus](#agentstatus) | | Yes |
|
||||
| updated_at | string | | No |
|
||||
| updated_by | string | | No |
|
||||
| workflow_id | string | | No |
|
||||
| workflow_node_id | string | | No |
|
||||
|
||||
#### AgentInviteOptionsQuery
|
||||
|
||||
| Name | Type | Description | Required |
|
||||
@ -10674,6 +10865,27 @@ Supported icon storage formats for Agent roster entries.
|
||||
| limit | integer | | No |
|
||||
| page | integer | | No |
|
||||
|
||||
#### AgentInviteOptionsResponse
|
||||
|
||||
| Name | Type | Description | Required |
|
||||
| ---- | ---- | ----------- | -------- |
|
||||
| data | [ [AgentInviteOptionResponse](#agentinviteoptionresponse) ] | | Yes |
|
||||
| has_more | boolean | | Yes |
|
||||
| limit | integer | | Yes |
|
||||
| page | integer | | Yes |
|
||||
| total | integer | | Yes |
|
||||
|
||||
#### AgentKind
|
||||
|
||||
Agent implementation family.
|
||||
|
||||
This leaves room for future non-Dify agent implementations while keeping
|
||||
the current roster/workflow APIs scoped to Dify Agent.
|
||||
|
||||
| Name | Type | Description | Required |
|
||||
| ---- | ---- | ----------- | -------- |
|
||||
| AgentKind | string | Agent implementation family. This leaves room for future non-Dify agent implementations while keeping the current roster/workflow APIs scoped to Dify Agent. | |
|
||||
|
||||
#### AgentKnowledgeQueryMode
|
||||
|
||||
| Name | Type | Description | Required |
|
||||
@ -10687,6 +10899,50 @@ Supported icon storage formats for Agent roster entries.
|
||||
| conversation_id | string | Conversation UUID | Yes |
|
||||
| message_id | string | Message UUID | Yes |
|
||||
|
||||
#### AgentRosterListResponse
|
||||
|
||||
| Name | Type | Description | Required |
|
||||
| ---- | ---- | ----------- | -------- |
|
||||
| data | [ [AgentRosterResponse](#agentrosterresponse) ] | | Yes |
|
||||
| has_more | boolean | | Yes |
|
||||
| limit | integer | | Yes |
|
||||
| page | integer | | Yes |
|
||||
| total | integer | | Yes |
|
||||
|
||||
#### AgentRosterResponse
|
||||
|
||||
| Name | Type | Description | Required |
|
||||
| ---- | ---- | ----------- | -------- |
|
||||
| active_config_snapshot | [AgentConfigSnapshotSummaryResponse](#agentconfigsnapshotsummaryresponse) | | No |
|
||||
| active_config_snapshot_id | string | | No |
|
||||
| agent_kind | [AgentKind](#agentkind) | | Yes |
|
||||
| app_id | string | | No |
|
||||
| archived_at | string | | No |
|
||||
| archived_by | string | | No |
|
||||
| created_at | string | | No |
|
||||
| created_by | string | | No |
|
||||
| description | string | | Yes |
|
||||
| icon | string | | No |
|
||||
| icon_background | string | | No |
|
||||
| icon_type | [AgentIconType](#agenticontype) | | No |
|
||||
| id | string | | Yes |
|
||||
| name | string | | Yes |
|
||||
| scope | [AgentScope](#agentscope) | | Yes |
|
||||
| source | [AgentSource](#agentsource) | | Yes |
|
||||
| status | [AgentStatus](#agentstatus) | | Yes |
|
||||
| updated_at | string | | No |
|
||||
| updated_by | string | | No |
|
||||
| workflow_id | string | | No |
|
||||
| workflow_node_id | string | | No |
|
||||
|
||||
#### AgentScope
|
||||
|
||||
Visibility and lifecycle scope of an Agent record.
|
||||
|
||||
| Name | Type | Description | Required |
|
||||
| ---- | ---- | ----------- | -------- |
|
||||
| AgentScope | string | Visibility and lifecycle scope of an Agent record. | |
|
||||
|
||||
#### AgentSoulConfig
|
||||
|
||||
| Name | Type | Description | Required |
|
||||
@ -10821,6 +11077,22 @@ Reference to model credentials resolved only at runtime.
|
||||
| cli_tools | [ object ] | | No |
|
||||
| dify_tools | [ [AgentSoulDifyToolConfig](#agentsouldifytoolconfig) ] | | No |
|
||||
|
||||
#### AgentSource
|
||||
|
||||
Origin that created or imported the Agent.
|
||||
|
||||
| Name | Type | Description | Required |
|
||||
| ---- | ---- | ----------- | -------- |
|
||||
| AgentSource | string | Origin that created or imported the Agent. | |
|
||||
|
||||
#### AgentStatus
|
||||
|
||||
Soft lifecycle state for Agent records.
|
||||
|
||||
| Name | Type | Description | Required |
|
||||
| ---- | ---- | ----------- | -------- |
|
||||
| AgentStatus | string | Soft lifecycle state for Agent records. | |
|
||||
|
||||
#### AgentThought
|
||||
|
||||
| Name | Type | Description | Required |
|
||||
@ -11073,6 +11345,7 @@ Enum class for api provider schema type.
|
||||
| access_mode | string | | No |
|
||||
| api_base_url | string | | No |
|
||||
| app_model_config | [ModelConfig](#modelconfig) | | No |
|
||||
| bound_agent_id | string | | No |
|
||||
| created_at | integer | | No |
|
||||
| created_by | string | | No |
|
||||
| deleted_tools | [ [DeletedTool](#deletedtool) ] | | No |
|
||||
@ -11403,7 +11676,7 @@ Button styles for user actions.
|
||||
| conversation_id | string | Conversation ID | No |
|
||||
| files | [ ] | Uploaded files | No |
|
||||
| inputs | object | | Yes |
|
||||
| model_config | object | | Yes |
|
||||
| model_config | object | | No |
|
||||
| parent_message_id | string | Parent message ID | No |
|
||||
| query | string | User query | Yes |
|
||||
| response_mode | string | Response mode<br>*Enum:* `"blocking"`, `"streaming"` | No |
|
||||
@ -11499,7 +11772,7 @@ Button styles for user actions.
|
||||
| ---- | ---- | ----------- | -------- |
|
||||
| files | [ ] | Uploaded files | No |
|
||||
| inputs | object | | Yes |
|
||||
| model_config | object | | Yes |
|
||||
| model_config | object | | No |
|
||||
| query | string | Query text | No |
|
||||
| response_mode | string | Response mode<br>*Enum:* `"blocking"`, `"streaming"` | No |
|
||||
| retriever_from | string | Retriever source | No |
|
||||
@ -11528,6 +11801,12 @@ Button styles for user actions.
|
||||
| binding_type | string | *Enum:* `"inline_agent"`, `"roster_agent"` | Yes |
|
||||
| current_snapshot_id | string | | No |
|
||||
|
||||
#### ComposerCandidateCapabilities
|
||||
|
||||
| Name | Type | Description | Required |
|
||||
| ---- | ---- | ----------- | -------- |
|
||||
| human_roster_available | boolean | | No |
|
||||
|
||||
#### ComposerSavePayload
|
||||
|
||||
| Name | Type | Description | Required |
|
||||
@ -15369,6 +15648,32 @@ in form definiton, or a variable while the workflow is running.
|
||||
| embedding_provider_name | string | | Yes |
|
||||
| vector_weight | number | | Yes |
|
||||
|
||||
#### WorkflowAgentBindingType
|
||||
|
||||
How a workflow node is bound to an Agent.
|
||||
|
||||
| Name | Type | Description | Required |
|
||||
| ---- | ---- | ----------- | -------- |
|
||||
| WorkflowAgentBindingType | string | How a workflow node is bound to an Agent. | |
|
||||
|
||||
#### WorkflowAgentComposerResponse
|
||||
|
||||
| Name | Type | Description | Required |
|
||||
| ---- | ---- | ----------- | -------- |
|
||||
| active_config_snapshot | [AgentConfigSnapshotSummaryResponse](#agentconfigsnapshotsummaryresponse) | | No |
|
||||
| agent | [AgentComposerAgentResponse](#agentcomposeragentresponse) | | No |
|
||||
| agent_soul | [AgentSoulConfig](#agentsoulconfig) | | Yes |
|
||||
| app_id | string | | No |
|
||||
| binding | [AgentComposerBindingResponse](#agentcomposerbindingresponse) | | No |
|
||||
| effective_declared_outputs | [ [DeclaredOutputConfig](#declaredoutputconfig) ] | | No |
|
||||
| impact_summary | [AgentComposerImpactResponse](#agentcomposerimpactresponse) | | No |
|
||||
| node_id | string | | No |
|
||||
| node_job | [WorkflowNodeJobConfig](#workflownodejobconfig) | | Yes |
|
||||
| save_options | [ [ComposerSaveStrategy](#composersavestrategy) ] | | Yes |
|
||||
| soul_lock | [AgentComposerSoulLockResponse](#agentcomposersoullockresponse) | | Yes |
|
||||
| variant | string | | Yes |
|
||||
| workflow_id | string | | No |
|
||||
|
||||
#### WorkflowAppLogPaginationResponse
|
||||
|
||||
| Name | Type | Description | Required |
|
||||
|
||||
@ -15,6 +15,7 @@ from models.agent import (
|
||||
AgentStatus,
|
||||
WorkflowAgentNodeBinding,
|
||||
)
|
||||
from models.agent_config_entities import AgentSoulConfig
|
||||
from models.workflow import Workflow
|
||||
from services.agent.composer_validator import ComposerConfigValidator
|
||||
from services.agent.errors import (
|
||||
@ -203,6 +204,83 @@ class AgentRosterService:
|
||||
raise AgentNameConflictError() from exc
|
||||
return agent
|
||||
|
||||
def create_backing_agent_for_app(
|
||||
self,
|
||||
*,
|
||||
tenant_id: str,
|
||||
account_id: str,
|
||||
app_id: str,
|
||||
name: str,
|
||||
description: str = "",
|
||||
icon_type: Any = None,
|
||||
icon: str | None = None,
|
||||
icon_background: str | None = None,
|
||||
) -> Agent:
|
||||
"""Create the roster Agent that backs an Agent App, linked via ``app_id``.
|
||||
|
||||
Unlike :meth:`create_roster_agent`, this does not commit: the caller
|
||||
(``AppService.create_app``) owns the surrounding transaction so the App
|
||||
row and its backing Agent are persisted atomically. A default (empty)
|
||||
Agent Soul is seeded; the user configures model/prompt/tools afterward in
|
||||
the Composer.
|
||||
"""
|
||||
agent = Agent(
|
||||
tenant_id=tenant_id,
|
||||
name=name,
|
||||
description=description,
|
||||
icon_type=icon_type,
|
||||
icon=icon,
|
||||
icon_background=icon_background,
|
||||
agent_kind=AgentKind.DIFY_AGENT,
|
||||
scope=AgentScope.ROSTER,
|
||||
source=AgentSource.AGENT_APP,
|
||||
status=AgentStatus.ACTIVE,
|
||||
app_id=app_id,
|
||||
created_by=account_id,
|
||||
updated_by=account_id,
|
||||
)
|
||||
self._session.add(agent)
|
||||
try:
|
||||
self._session.flush()
|
||||
except IntegrityError as exc:
|
||||
self._session.rollback()
|
||||
raise AgentNameConflictError() from exc
|
||||
|
||||
version = AgentConfigSnapshot(
|
||||
tenant_id=tenant_id,
|
||||
agent_id=agent.id,
|
||||
version=1,
|
||||
config_snapshot=AgentSoulConfig(),
|
||||
created_by=account_id,
|
||||
)
|
||||
self._session.add(version)
|
||||
self._session.flush()
|
||||
|
||||
revision = AgentConfigRevision(
|
||||
tenant_id=tenant_id,
|
||||
agent_id=agent.id,
|
||||
current_snapshot_id=version.id,
|
||||
revision=1,
|
||||
operation=AgentConfigRevisionOperation.CREATE_VERSION,
|
||||
created_by=account_id,
|
||||
)
|
||||
self._session.add(revision)
|
||||
agent.active_config_snapshot_id = version.id
|
||||
self._session.flush()
|
||||
return agent
|
||||
|
||||
def get_app_backing_agent(self, *, tenant_id: str, app_id: str) -> Agent | None:
|
||||
"""Return the roster Agent that backs the given Agent App, if any."""
|
||||
return self._session.scalar(
|
||||
select(Agent).where(
|
||||
Agent.tenant_id == tenant_id,
|
||||
Agent.app_id == app_id,
|
||||
Agent.scope == AgentScope.ROSTER,
|
||||
Agent.source == AgentSource.AGENT_APP,
|
||||
Agent.status == AgentStatus.ACTIVE,
|
||||
)
|
||||
)
|
||||
|
||||
def get_roster_agent_detail(self, *, tenant_id: str, agent_id: str) -> dict[str, Any]:
|
||||
agent = self._get_agent(tenant_id=tenant_id, agent_id=agent_id, roster_only=True)
|
||||
active_version = self._get_version(
|
||||
|
||||
@ -8,6 +8,7 @@ from typing import TYPE_CHECKING, Any
|
||||
|
||||
from configs import dify_config
|
||||
from core.app.apps.advanced_chat.app_generator import AdvancedChatAppGenerator
|
||||
from core.app.apps.agent_app.app_generator import AgentAppGenerator
|
||||
from core.app.apps.agent_chat.app_generator import AgentChatAppGenerator
|
||||
from core.app.apps.chat.app_generator import ChatAppGenerator
|
||||
from core.app.apps.completion.app_generator import CompletionAppGenerator
|
||||
@ -140,6 +141,15 @@ class AppGenerateService:
|
||||
),
|
||||
request_id,
|
||||
)
|
||||
case AppMode.AGENT:
|
||||
return rate_limit.generate(
|
||||
AgentAppGenerator.convert_to_event_stream(
|
||||
AgentAppGenerator().generate(
|
||||
app_model=app_model, user=user, args=args, invoke_from=invoke_from, streaming=streaming
|
||||
),
|
||||
),
|
||||
request_id,
|
||||
)
|
||||
case AppMode.CHAT:
|
||||
return rate_limit.generate(
|
||||
ChatAppGenerator.convert_to_event_stream(
|
||||
|
||||
@ -23,6 +23,7 @@ from graphon.model_runtime.model_providers.base.large_language_model import Larg
|
||||
from libs.datetime_utils import naive_utc_now
|
||||
from libs.login import current_user
|
||||
from models import Account
|
||||
from models.agent import AgentIconType
|
||||
from models.model import App, AppMode, AppModelConfig, IconType, Site
|
||||
from models.tools import ApiToolProvider
|
||||
from services.billing_service import BillingService
|
||||
@ -38,7 +39,7 @@ logger = logging.getLogger(__name__)
|
||||
class AppListParams(BaseModel):
|
||||
page: int = Field(default=1, ge=1)
|
||||
limit: int = Field(default=20, ge=1, le=100)
|
||||
mode: Literal["completion", "chat", "advanced-chat", "workflow", "agent-chat", "channel", "all"] = "all"
|
||||
mode: Literal["completion", "chat", "advanced-chat", "workflow", "agent-chat", "agent", "channel", "all"] = "all"
|
||||
name: str | None = None
|
||||
tag_ids: list[str] | None = None
|
||||
is_created_by_me: bool | None = None
|
||||
@ -49,7 +50,7 @@ class AppListParams(BaseModel):
|
||||
class CreateAppParams(BaseModel):
|
||||
name: str = Field(min_length=1)
|
||||
description: str | None = None
|
||||
mode: Literal["chat", "agent-chat", "advanced-chat", "workflow", "completion"]
|
||||
mode: Literal["chat", "agent-chat", "agent", "advanced-chat", "workflow", "completion"]
|
||||
icon_type: str | None = None
|
||||
icon: str | None = None
|
||||
icon_background: str | None = None
|
||||
@ -124,6 +125,8 @@ class AppService:
|
||||
filters.append(App.mode == AppMode.ADVANCED_CHAT)
|
||||
elif params.mode == "agent-chat":
|
||||
filters.append(App.mode == AppMode.AGENT_CHAT)
|
||||
elif params.mode == "agent":
|
||||
filters.append(App.mode == AppMode.AGENT)
|
||||
|
||||
if params.status:
|
||||
filters.append(App.status == params.status)
|
||||
@ -246,6 +249,39 @@ class AppService:
|
||||
db.session.flush()
|
||||
|
||||
app.app_model_config_id = app_model_config.id
|
||||
elif app_mode == AppMode.AGENT:
|
||||
# An Agent App keeps its model / prompt / tools in the bound Agent
|
||||
# Soul, so the app_model_config row carries no model — only the
|
||||
# app-level presentation features the PRD requires (conversation
|
||||
# opener, follow-up suggestions, citations, moderation, annotation).
|
||||
# They default to disabled/empty here and are read by both the
|
||||
# webapp /parameters endpoint and the chat pipeline. agent_mode is
|
||||
# left unset so App.is_agent stays False (this is the new Agent App
|
||||
# type, not a legacy function-call/react agent).
|
||||
agent_app_model_config = AppModelConfig(app_id=app.id, created_by=account.id, updated_by=account.id)
|
||||
db.session.add(agent_app_model_config)
|
||||
db.session.flush()
|
||||
|
||||
app.app_model_config_id = agent_app_model_config.id
|
||||
|
||||
# Agent App type is backed 1:1 by a roster Agent (linked via Agent.app_id).
|
||||
# Created in the same transaction so the App and its backing Agent persist
|
||||
# atomically; the Agent Soul (model/prompt/tools) is configured afterward
|
||||
# in the Composer.
|
||||
if app_mode == AppMode.AGENT:
|
||||
from services.agent.roster_service import AgentRosterService
|
||||
|
||||
icon_type = AgentIconType(params.icon_type) if params.icon_type else None
|
||||
AgentRosterService(db.session).create_backing_agent_for_app(
|
||||
tenant_id=tenant_id,
|
||||
account_id=account.id,
|
||||
app_id=app.id,
|
||||
name=params.name,
|
||||
description=params.description or "",
|
||||
icon_type=icon_type,
|
||||
icon=params.icon,
|
||||
icon_background=params.icon_background,
|
||||
)
|
||||
|
||||
db.session.commit()
|
||||
|
||||
|
||||
@ -30,6 +30,90 @@ def _unwrap(method):
|
||||
return method
|
||||
|
||||
|
||||
def _agent_response(agent_id: str = "agent-1") -> dict:
|
||||
return {
|
||||
"id": agent_id,
|
||||
"name": "Analyst",
|
||||
"description": "",
|
||||
"icon_type": None,
|
||||
"icon": None,
|
||||
"icon_background": None,
|
||||
"agent_kind": "dify_agent",
|
||||
"scope": "roster",
|
||||
"source": "agent_app",
|
||||
"app_id": None,
|
||||
"workflow_id": None,
|
||||
"workflow_node_id": None,
|
||||
"active_config_snapshot_id": "version-1",
|
||||
"active_config_snapshot": _version_response(),
|
||||
"status": "active",
|
||||
"created_by": "account-1",
|
||||
"updated_by": "account-1",
|
||||
"archived_by": None,
|
||||
"archived_at": None,
|
||||
"created_at": None,
|
||||
"updated_at": None,
|
||||
}
|
||||
|
||||
|
||||
def _version_response(version_id: str = "version-1") -> dict:
|
||||
return {
|
||||
"id": version_id,
|
||||
"agent_id": "agent-1",
|
||||
"version": 1,
|
||||
"summary": None,
|
||||
"version_note": None,
|
||||
"created_by": "account-1",
|
||||
"created_at": None,
|
||||
}
|
||||
|
||||
|
||||
def _workflow_composer_response(**overrides) -> dict:
|
||||
response = {
|
||||
"variant": "workflow",
|
||||
"agent": None,
|
||||
"active_config_snapshot": None,
|
||||
"binding": None,
|
||||
"soul_lock": {"locked": False, "can_unlock": False, "reason": "workflow_only_empty"},
|
||||
"agent_soul": {},
|
||||
"node_job": {},
|
||||
"effective_declared_outputs": [],
|
||||
"save_options": ["node_job_only"],
|
||||
"impact_summary": None,
|
||||
"app_id": "app-1",
|
||||
"workflow_id": "workflow-1",
|
||||
"node_id": "node-1",
|
||||
}
|
||||
response.update(overrides)
|
||||
return response
|
||||
|
||||
|
||||
def _agent_app_composer_response() -> dict:
|
||||
return {
|
||||
"variant": "agent_app",
|
||||
"agent": {
|
||||
"id": "agent-1",
|
||||
"name": "Analyst",
|
||||
"description": "",
|
||||
"scope": "roster",
|
||||
"status": "active",
|
||||
"active_config_snapshot_id": "version-1",
|
||||
},
|
||||
"active_config_snapshot": _version_response(),
|
||||
"agent_soul": {},
|
||||
"save_options": ["save_to_current_version", "save_as_new_version"],
|
||||
}
|
||||
|
||||
|
||||
def _candidates_response(variant: str) -> dict:
|
||||
return {
|
||||
"variant": variant,
|
||||
"allowed_node_job_candidates": {},
|
||||
"allowed_soul_candidates": {},
|
||||
"capabilities": {"human_roster_available": False},
|
||||
}
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def account():
|
||||
return SimpleNamespace(id="account-1")
|
||||
@ -67,14 +151,15 @@ def test_roster_list_post_creates_agent_and_returns_detail(app, monkeypatch):
|
||||
monkeypatch.setattr(
|
||||
roster_controller.AgentRosterService,
|
||||
"get_roster_agent_detail",
|
||||
lambda _self, **kwargs: {"id": kwargs["agent_id"], "tenant_id": kwargs["tenant_id"]},
|
||||
lambda _self, **kwargs: _agent_response(kwargs["agent_id"]),
|
||||
)
|
||||
|
||||
with app.test_request_context(json={"name": "Analyst", "agent_soul": {"prompt": {"system_prompt": "x"}}}):
|
||||
result, status = _unwrap(AgentRosterListApi.post)(AgentRosterListApi())
|
||||
|
||||
assert status == 201
|
||||
assert result == {"id": "agent-1", "tenant_id": "tenant-1"}
|
||||
assert result["id"] == "agent-1"
|
||||
assert result["agent_kind"] == "dify_agent"
|
||||
|
||||
|
||||
def test_invite_options_get_parses_app_id(app, monkeypatch):
|
||||
@ -82,14 +167,14 @@ def test_invite_options_get_parses_app_id(app, monkeypatch):
|
||||
|
||||
def list_invite_options(_self, **kwargs):
|
||||
captured.update(kwargs)
|
||||
return {"data": []}
|
||||
return {"data": [], "page": kwargs["page"], "limit": kwargs["limit"], "total": 0, "has_more": False}
|
||||
|
||||
monkeypatch.setattr(roster_controller.AgentRosterService, "list_invite_options", list_invite_options)
|
||||
|
||||
with app.test_request_context("/console/api/agents/invite-options?page=1&limit=10&app_id=app-1"):
|
||||
result = _unwrap(AgentInviteOptionsApi.get)(AgentInviteOptionsApi())
|
||||
|
||||
assert result == {"data": []}
|
||||
assert result == {"data": [], "page": 1, "limit": 10, "total": 0, "has_more": False}
|
||||
assert captured == {"tenant_id": "tenant-1", "page": 1, "limit": 10, "keyword": None, "app_id": "app-1"}
|
||||
|
||||
|
||||
@ -100,12 +185,12 @@ def test_roster_detail_patch_delete_and_versions_call_services(app, monkeypatch)
|
||||
monkeypatch.setattr(
|
||||
roster_controller.AgentRosterService,
|
||||
"get_roster_agent_detail",
|
||||
lambda _self, **kwargs: {"id": kwargs["agent_id"]},
|
||||
lambda _self, **kwargs: _agent_response(kwargs["agent_id"]),
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
roster_controller.AgentRosterService,
|
||||
"update_roster_agent",
|
||||
lambda _self, **kwargs: {"id": kwargs["agent_id"], "description": kwargs["payload"].description},
|
||||
lambda _self, **kwargs: {**_agent_response(kwargs["agent_id"]), "description": kwargs["payload"].description},
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
roster_controller.AgentRosterService,
|
||||
@ -115,12 +200,29 @@ def test_roster_detail_patch_delete_and_versions_call_services(app, monkeypatch)
|
||||
monkeypatch.setattr(
|
||||
roster_controller.AgentRosterService,
|
||||
"list_agent_versions",
|
||||
lambda _self, **kwargs: [{"id": "version-1"}],
|
||||
lambda _self, **kwargs: [_version_response()],
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
roster_controller.AgentRosterService,
|
||||
"get_agent_version_detail",
|
||||
lambda _self, **kwargs: {"id": kwargs["version_id"], "agent_id": kwargs["agent_id"]},
|
||||
lambda _self, **kwargs: {
|
||||
**_version_response(kwargs["version_id"]),
|
||||
"agent_id": kwargs["agent_id"],
|
||||
"config_snapshot": {},
|
||||
"revisions": [
|
||||
{
|
||||
"id": "revision-1",
|
||||
"previous_snapshot_id": None,
|
||||
"current_snapshot_id": kwargs["version_id"],
|
||||
"revision": 1,
|
||||
"operation": "create_version",
|
||||
"summary": None,
|
||||
"version_note": None,
|
||||
"created_by": "account-1",
|
||||
"created_at": None,
|
||||
}
|
||||
],
|
||||
},
|
||||
)
|
||||
|
||||
assert _unwrap(AgentRosterDetailApi.get)(AgentRosterDetailApi(), agent_id)["id"] == agent_id
|
||||
@ -128,11 +230,10 @@ def test_roster_detail_patch_delete_and_versions_call_services(app, monkeypatch)
|
||||
assert _unwrap(AgentRosterDetailApi.patch)(AgentRosterDetailApi(), agent_id)["description"] == "updated"
|
||||
assert _unwrap(AgentRosterDetailApi.delete)(AgentRosterDetailApi(), agent_id) == ("", 204)
|
||||
assert archived["account_id"] == "account-1"
|
||||
assert _unwrap(AgentRosterVersionsApi.get)(AgentRosterVersionsApi(), agent_id) == {"data": [{"id": "version-1"}]}
|
||||
assert _unwrap(AgentRosterVersionDetailApi.get)(AgentRosterVersionDetailApi(), agent_id, version_id) == {
|
||||
"id": version_id,
|
||||
"agent_id": agent_id,
|
||||
}
|
||||
assert _unwrap(AgentRosterVersionsApi.get)(AgentRosterVersionsApi(), agent_id)["data"][0]["id"] == "version-1"
|
||||
version_detail = _unwrap(AgentRosterVersionDetailApi.get)(AgentRosterVersionDetailApi(), agent_id, version_id)
|
||||
assert version_detail["id"] == version_id
|
||||
assert version_detail["agent_id"] == agent_id
|
||||
|
||||
|
||||
def test_workflow_composer_get_put_validate_candidates_impact_and_save(app, monkeypatch):
|
||||
@ -145,50 +246,52 @@ def test_workflow_composer_get_put_validate_candidates_impact_and_save(app, monk
|
||||
monkeypatch.setattr(
|
||||
composer_controller.AgentComposerService,
|
||||
"load_workflow_composer",
|
||||
lambda **kwargs: {"node_id": kwargs["node_id"]},
|
||||
lambda **kwargs: _workflow_composer_response(node_id=kwargs["node_id"]),
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
composer_controller.AgentComposerService,
|
||||
"save_workflow_composer",
|
||||
lambda **kwargs: {"saved": kwargs["payload"].save_strategy.value, "account_id": kwargs["account_id"]},
|
||||
lambda **kwargs: _workflow_composer_response(save_options=[kwargs["payload"].save_strategy.value]),
|
||||
)
|
||||
monkeypatch.setattr(composer_controller.ComposerConfigValidator, "validate_save_payload", lambda payload: None)
|
||||
monkeypatch.setattr(
|
||||
composer_controller.AgentComposerService,
|
||||
"get_workflow_candidates",
|
||||
lambda **kwargs: {"data": []},
|
||||
lambda **kwargs: _candidates_response("workflow"),
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
composer_controller.AgentComposerService,
|
||||
"calculate_impact",
|
||||
lambda **kwargs: {"current_snapshot_id": kwargs["current_snapshot_id"], "workflow_node_count": 1},
|
||||
lambda **kwargs: {
|
||||
"current_snapshot_id": kwargs["current_snapshot_id"],
|
||||
"workflow_node_count": 1,
|
||||
"bindings": [],
|
||||
},
|
||||
)
|
||||
|
||||
assert _unwrap(WorkflowAgentComposerApi.get)(WorkflowAgentComposerApi(), app_model, "node-1") == {
|
||||
"node_id": "node-1"
|
||||
}
|
||||
workflow_state = _unwrap(WorkflowAgentComposerApi.get)(WorkflowAgentComposerApi(), app_model, "node-1")
|
||||
assert workflow_state["node_id"] == "node-1"
|
||||
with app.test_request_context(json=payload):
|
||||
assert _unwrap(WorkflowAgentComposerApi.put)(WorkflowAgentComposerApi(), app_model, "node-1") == {
|
||||
"saved": "node_job_only",
|
||||
"account_id": "account-1",
|
||||
}
|
||||
saved_state = _unwrap(WorkflowAgentComposerApi.put)(WorkflowAgentComposerApi(), app_model, "node-1")
|
||||
assert saved_state["save_options"] == ["node_job_only"]
|
||||
assert _unwrap(WorkflowAgentComposerValidateApi.post)(
|
||||
WorkflowAgentComposerValidateApi(), app_model, "node-1"
|
||||
) == {"result": "success", "errors": []}
|
||||
assert _unwrap(WorkflowAgentComposerCandidatesApi.get)(
|
||||
WorkflowAgentComposerCandidatesApi(), app_model, "node-1"
|
||||
) == {"data": []}
|
||||
assert (
|
||||
_unwrap(WorkflowAgentComposerCandidatesApi.get)(WorkflowAgentComposerCandidatesApi(), app_model, "node-1")[
|
||||
"variant"
|
||||
]
|
||||
== "workflow"
|
||||
)
|
||||
with app.test_request_context(json=payload):
|
||||
assert _unwrap(WorkflowAgentComposerImpactApi.post)(WorkflowAgentComposerImpactApi(), app_model, "node-1") == {
|
||||
"current_snapshot_id": "version-1",
|
||||
"workflow_node_count": 1,
|
||||
"bindings": [],
|
||||
}
|
||||
assert (
|
||||
_unwrap(WorkflowAgentComposerSaveToRosterApi.post)(
|
||||
WorkflowAgentComposerSaveToRosterApi(), app_model, "node-1"
|
||||
)["saved"]
|
||||
== "node_job_only"
|
||||
)
|
||||
assert _unwrap(WorkflowAgentComposerSaveToRosterApi.post)(
|
||||
WorkflowAgentComposerSaveToRosterApi(), app_model, "node-1"
|
||||
)["save_options"] == ["node_job_only"]
|
||||
|
||||
|
||||
def test_workflow_impact_returns_empty_without_version(app):
|
||||
@ -212,28 +315,26 @@ def test_agent_app_composer_get_put_validate_and_candidates(app, monkeypatch):
|
||||
monkeypatch.setattr(
|
||||
composer_controller.AgentComposerService,
|
||||
"load_agent_app_composer",
|
||||
lambda **kwargs: {"loaded": True},
|
||||
lambda **kwargs: _agent_app_composer_response(),
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
composer_controller.AgentComposerService,
|
||||
"save_agent_app_composer",
|
||||
lambda **kwargs: {"saved": kwargs["payload"].variant.value, "account_id": kwargs["account_id"]},
|
||||
lambda **kwargs: _agent_app_composer_response(),
|
||||
)
|
||||
monkeypatch.setattr(composer_controller.ComposerConfigValidator, "validate_save_payload", lambda payload: None)
|
||||
monkeypatch.setattr(
|
||||
composer_controller.AgentComposerService,
|
||||
"get_agent_app_candidates",
|
||||
lambda **kwargs: {"data": []},
|
||||
lambda **kwargs: _candidates_response("agent_app"),
|
||||
)
|
||||
|
||||
assert _unwrap(AgentAppComposerApi.get)(AgentAppComposerApi(), app_model) == {"loaded": True}
|
||||
assert _unwrap(AgentAppComposerApi.get)(AgentAppComposerApi(), app_model)["variant"] == "agent_app"
|
||||
with app.test_request_context(json=payload):
|
||||
assert _unwrap(AgentAppComposerApi.put)(AgentAppComposerApi(), app_model) == {
|
||||
"saved": "agent_app",
|
||||
"account_id": "account-1",
|
||||
}
|
||||
assert _unwrap(AgentAppComposerApi.put)(AgentAppComposerApi(), app_model)["variant"] == "agent_app"
|
||||
assert _unwrap(AgentAppComposerValidateApi.post)(AgentAppComposerValidateApi(), app_model) == {
|
||||
"result": "success",
|
||||
"errors": [],
|
||||
}
|
||||
assert _unwrap(AgentAppComposerCandidatesApi.get)(AgentAppComposerCandidatesApi(), app_model) == {"data": []}
|
||||
agent_app_candidates = _unwrap(AgentAppComposerCandidatesApi.get)(AgentAppComposerCandidatesApi(), app_model)
|
||||
assert agent_app_candidates["variant"] == "agent_app"
|
||||
|
||||
@ -0,0 +1,70 @@
|
||||
"""Unit tests for get_app_parameters — the per-app-type webapp parameters resolver.
|
||||
|
||||
Covers the four branches: workflow-backed apps, easy-UI apps with an
|
||||
app_model_config, Agent Apps (no config, defaults), and the unavailable case.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from types import SimpleNamespace
|
||||
|
||||
import pytest
|
||||
|
||||
from core.app.app_config.common.parameters_mapping import (
|
||||
AppParametersUnavailableError,
|
||||
get_app_parameters,
|
||||
)
|
||||
from models.model import AppMode
|
||||
|
||||
|
||||
def test_agent_app_defaults_to_disabled_features():
|
||||
# An Agent App has no workflow and no app_model_config; every toggle defaults
|
||||
# to disabled and the input form is empty (free-form chat box).
|
||||
app_model = SimpleNamespace(mode=AppMode.AGENT, workflow=None, app_model_config=None)
|
||||
params = get_app_parameters(app_model) # type: ignore[arg-type]
|
||||
|
||||
assert params["opening_statement"] is None
|
||||
assert params["user_input_form"] == []
|
||||
assert params["suggested_questions_after_answer"] == {"enabled": False}
|
||||
assert params["file_upload"]["image"]["enabled"] is False
|
||||
|
||||
|
||||
def test_easy_ui_app_reads_from_app_model_config():
|
||||
amc = SimpleNamespace(
|
||||
to_dict=lambda: {
|
||||
"opening_statement": "Hi there!",
|
||||
"user_input_form": [{"text-input": {"variable": "name"}}],
|
||||
"suggested_questions_after_answer": {"enabled": True},
|
||||
}
|
||||
)
|
||||
app_model = SimpleNamespace(mode=AppMode.CHAT, workflow=None, app_model_config=amc)
|
||||
params = get_app_parameters(app_model) # type: ignore[arg-type]
|
||||
|
||||
assert params["opening_statement"] == "Hi there!"
|
||||
assert params["user_input_form"] == [{"text-input": {"variable": "name"}}]
|
||||
assert params["suggested_questions_after_answer"] == {"enabled": True}
|
||||
|
||||
|
||||
def test_workflow_app_reads_from_workflow():
|
||||
workflow = SimpleNamespace(
|
||||
features_dict={"opening_statement": "From workflow"},
|
||||
user_input_form=lambda to_old_structure: [{"paragraph": {"variable": "q"}}],
|
||||
)
|
||||
app_model = SimpleNamespace(mode=AppMode.ADVANCED_CHAT, workflow=workflow, app_model_config=None)
|
||||
params = get_app_parameters(app_model) # type: ignore[arg-type]
|
||||
|
||||
assert params["opening_statement"] == "From workflow"
|
||||
assert params["user_input_form"] == [{"paragraph": {"variable": "q"}}]
|
||||
|
||||
|
||||
def test_workflow_app_without_workflow_is_unavailable():
|
||||
app_model = SimpleNamespace(mode=AppMode.WORKFLOW, workflow=None, app_model_config=None)
|
||||
with pytest.raises(AppParametersUnavailableError):
|
||||
get_app_parameters(app_model) # type: ignore[arg-type]
|
||||
|
||||
|
||||
def test_easy_ui_app_without_config_is_unavailable():
|
||||
# A chat app with no published config is genuinely unavailable (unlike Agent).
|
||||
app_model = SimpleNamespace(mode=AppMode.CHAT, workflow=None, app_model_config=None)
|
||||
with pytest.raises(AppParametersUnavailableError):
|
||||
get_app_parameters(app_model) # type: ignore[arg-type]
|
||||
@ -0,0 +1,84 @@
|
||||
"""Unit tests for AgentAppConfigManager._synthesize_config_dict — the Soul →
|
||||
app_model_config-shaped dict bridge that lets an Agent App ride the chat pipeline."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from types import SimpleNamespace
|
||||
|
||||
from core.app.apps.agent_app.app_config_manager import AgentAppConfigManager
|
||||
from models.agent_config_entities import AgentSoulConfig
|
||||
|
||||
|
||||
def _soul() -> AgentSoulConfig:
|
||||
return AgentSoulConfig.model_validate(
|
||||
{
|
||||
"model": {
|
||||
"plugin_id": "langgenius/openai",
|
||||
"model_provider": "langgenius/openai/openai",
|
||||
"model": "gpt-4o-mini",
|
||||
"model_settings": {"temperature": 0.2},
|
||||
},
|
||||
"prompt": {"system_prompt": "You are Iris."},
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def test_model_and_prompt_come_from_soul():
|
||||
d = AgentAppConfigManager._synthesize_config_dict(_soul(), None)
|
||||
assert d["model"] == {
|
||||
"provider": "langgenius/openai/openai",
|
||||
"name": "gpt-4o-mini",
|
||||
"mode": "chat",
|
||||
"completion_params": {"temperature": 0.2},
|
||||
}
|
||||
assert d["pre_prompt"] == "You are Iris."
|
||||
assert d["user_input_form"] == []
|
||||
|
||||
|
||||
def test_feature_flags_come_from_app_model_config_when_present():
|
||||
# Q3: opener/follow-up/etc. live on app_model_config; model/prompt stay from Soul.
|
||||
fake_amc = SimpleNamespace(
|
||||
to_dict=lambda: {
|
||||
"opening_statement": "Hi, I'm Iris.",
|
||||
"suggested_questions_after_answer": {"enabled": True},
|
||||
"model": {"provider": "should-be-overridden", "name": "old"},
|
||||
"pre_prompt": "old prompt",
|
||||
}
|
||||
)
|
||||
d = AgentAppConfigManager._synthesize_config_dict(_soul(), fake_amc) # type: ignore[arg-type]
|
||||
# feature flags preserved
|
||||
assert d["opening_statement"] == "Hi, I'm Iris."
|
||||
assert d["suggested_questions_after_answer"] == {"enabled": True}
|
||||
# model + prompt overridden by Soul (single source of truth)
|
||||
assert d["model"]["name"] == "gpt-4o-mini"
|
||||
assert d["pre_prompt"] == "You are Iris."
|
||||
|
||||
|
||||
def test_missing_soul_model_leaves_no_model_key():
|
||||
d = AgentAppConfigManager._synthesize_config_dict(AgentSoulConfig(), None)
|
||||
assert "model" not in d
|
||||
assert d["pre_prompt"] == ""
|
||||
|
||||
|
||||
def test_prompt_type_defaults_to_simple():
|
||||
# PromptTemplateConfigManager.convert requires prompt_type; an Agent App with
|
||||
# no legacy app_model_config must still get the "simple" slot synthesized.
|
||||
d = AgentAppConfigManager._synthesize_config_dict(_soul(), None)
|
||||
assert d["prompt_type"] == "simple"
|
||||
|
||||
|
||||
def test_get_app_config_has_null_model_config_id_without_legacy_row():
|
||||
# An Agent App has no app_model_config row; the conversation's
|
||||
# app_model_config_id (a UUID column) must be NULL, not "".
|
||||
app_model = SimpleNamespace(
|
||||
tenant_id="11111111-1111-1111-1111-111111111111",
|
||||
id="22222222-2222-2222-2222-222222222222",
|
||||
mode="agent",
|
||||
)
|
||||
app_config = AgentAppConfigManager.get_app_config(
|
||||
app_model=app_model, # type: ignore[arg-type]
|
||||
agent_soul=_soul(),
|
||||
app_model_config=None,
|
||||
conversation=None,
|
||||
)
|
||||
assert app_config.app_model_config_id is None
|
||||
150
api/tests/unit_tests/core/app/apps/agent_app/test_app_runner.py
Normal file
150
api/tests/unit_tests/core/app/apps/agent_app/test_app_runner.py
Normal file
@ -0,0 +1,150 @@
|
||||
"""Unit tests for the Agent App runner — verifies the agent-backend event
|
||||
stream is republished as chat queue events and the conversation snapshot is
|
||||
saved, using the deterministic fake backend client (no live stack)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from types import SimpleNamespace
|
||||
from typing import Any
|
||||
|
||||
import pytest
|
||||
from agenton.compositor import CompositorSessionSnapshot
|
||||
|
||||
from clients.agent_backend import (
|
||||
AgentBackendError,
|
||||
AgentBackendRunEventAdapter,
|
||||
FakeAgentBackendRunClient,
|
||||
FakeAgentBackendScenario,
|
||||
)
|
||||
from core.app.apps.agent_app.app_runner import AgentAppRunner
|
||||
from core.app.apps.agent_app.runtime_request_builder import AgentAppRuntimeRequestBuilder
|
||||
from core.app.apps.agent_app.session_store import AgentAppSessionScope
|
||||
from core.app.entities.app_invoke_entities import InvokeFrom
|
||||
from core.app.entities.queue_entities import QueueLLMChunkEvent, QueueMessageEndEvent
|
||||
from models.agent_config_entities import AgentSoulConfig
|
||||
|
||||
|
||||
class _FakeCredentialsProvider:
|
||||
def fetch(self, provider_name: str, model_name: str) -> dict[str, Any]:
|
||||
return {"openai_api_key": "sk-test"}
|
||||
|
||||
|
||||
class _NoToolsBuilder:
|
||||
def build(self, **kwargs):
|
||||
del kwargs
|
||||
|
||||
|
||||
class _FakeQueueManager:
|
||||
def __init__(self) -> None:
|
||||
self.events: list[Any] = []
|
||||
|
||||
def publish(self, event: Any, _from: Any) -> None:
|
||||
self.events.append(event)
|
||||
|
||||
|
||||
class _FakeSessionStore:
|
||||
def __init__(self, loaded: CompositorSessionSnapshot | None = None) -> None:
|
||||
self.loaded = loaded
|
||||
self.saved: list[tuple[AgentAppSessionScope, str, CompositorSessionSnapshot | None]] = []
|
||||
|
||||
def load_active_snapshot(self, scope: AgentAppSessionScope) -> CompositorSessionSnapshot | None:
|
||||
return self.loaded
|
||||
|
||||
def save_active_snapshot(self, *, scope, backend_run_id, snapshot) -> None:
|
||||
self.saved.append((scope, backend_run_id, snapshot))
|
||||
|
||||
|
||||
def _soul() -> AgentSoulConfig:
|
||||
return AgentSoulConfig.model_validate(
|
||||
{
|
||||
"model": {
|
||||
"plugin_id": "langgenius/openai",
|
||||
"model_provider": "langgenius/openai/openai",
|
||||
"model": "gpt-4o-mini",
|
||||
},
|
||||
"prompt": {"system_prompt": "You are Iris."},
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def _dify_ctx() -> Any:
|
||||
return SimpleNamespace(tenant_id="tenant-1", app_id="app-1", user_id="user-1", invoke_from=InvokeFrom.WEB_APP)
|
||||
|
||||
|
||||
def _runner(client: FakeAgentBackendRunClient, store: _FakeSessionStore) -> AgentAppRunner:
|
||||
return AgentAppRunner(
|
||||
request_builder=AgentAppRuntimeRequestBuilder(
|
||||
credentials_provider=_FakeCredentialsProvider(),
|
||||
plugin_tools_builder=_NoToolsBuilder(), # type: ignore[arg-type]
|
||||
),
|
||||
agent_backend_client=client,
|
||||
event_adapter=AgentBackendRunEventAdapter(),
|
||||
session_store=store, # type: ignore[arg-type]
|
||||
)
|
||||
|
||||
|
||||
def _run(runner: AgentAppRunner, qm: _FakeQueueManager) -> None:
|
||||
runner.run(
|
||||
dify_context=_dify_ctx(),
|
||||
agent_id="agent-1",
|
||||
agent_config_snapshot_id="snap-1",
|
||||
agent_soul=_soul(),
|
||||
conversation_id="conv-1",
|
||||
query="hello",
|
||||
message_id="msg-1",
|
||||
model_name="gpt-4o-mini",
|
||||
queue_manager=qm, # type: ignore[arg-type]
|
||||
)
|
||||
|
||||
|
||||
def test_successful_turn_publishes_chunk_and_message_end_and_saves_session():
|
||||
client = FakeAgentBackendRunClient() # SUCCESS: output {"text": "hello agent"}
|
||||
store = _FakeSessionStore()
|
||||
qm = _FakeQueueManager()
|
||||
|
||||
_run(_runner(client, store), qm)
|
||||
|
||||
# One LLM chunk + one message-end, carrying the backend's answer text.
|
||||
chunk_events = [e for e in qm.events if isinstance(e, QueueLLMChunkEvent)]
|
||||
end_events = [e for e in qm.events if isinstance(e, QueueMessageEndEvent)]
|
||||
assert len(chunk_events) == 1
|
||||
assert len(end_events) == 1
|
||||
assert chunk_events[0].chunk.delta.message.content == "hello agent"
|
||||
assert end_events[0].llm_result.message.content == "hello agent"
|
||||
assert end_events[0].llm_result.model == "gpt-4o-mini"
|
||||
# The conversation session snapshot is persisted for multi-turn continuity.
|
||||
assert store.saved
|
||||
saved_scope, saved_run_id, saved_snapshot = store.saved[0]
|
||||
assert saved_scope.conversation_id == "conv-1"
|
||||
assert saved_run_id == "fake-run-1"
|
||||
assert saved_snapshot is not None
|
||||
|
||||
|
||||
def test_prior_session_snapshot_is_threaded_into_request():
|
||||
prior = CompositorSessionSnapshot(layers=[])
|
||||
client = FakeAgentBackendRunClient()
|
||||
store = _FakeSessionStore(loaded=prior)
|
||||
qm = _FakeQueueManager()
|
||||
|
||||
_run(_runner(client, store), qm)
|
||||
|
||||
assert client.request is not None
|
||||
assert client.request.session_snapshot is prior
|
||||
|
||||
|
||||
def test_failed_run_raises_agent_backend_error():
|
||||
client = FakeAgentBackendRunClient(scenario=FakeAgentBackendScenario.FAILED)
|
||||
store = _FakeSessionStore()
|
||||
qm = _FakeQueueManager()
|
||||
|
||||
with pytest.raises(AgentBackendError):
|
||||
_run(_runner(client, store), qm)
|
||||
# No message-end on failure; no snapshot saved.
|
||||
assert not [e for e in qm.events if isinstance(e, QueueMessageEndEvent)]
|
||||
assert store.saved == []
|
||||
|
||||
|
||||
def test_extract_answer_handles_plain_string_and_dict():
|
||||
assert AgentAppRunner._extract_answer("plain text") == "plain text"
|
||||
assert AgentAppRunner._extract_answer({"text": "hi"}) == "hi"
|
||||
assert AgentAppRunner._extract_answer({"a": 1}) == '{"a": 1}'
|
||||
@ -0,0 +1,144 @@
|
||||
"""Unit tests for the Agent App runtime request builder + the app-shaped
|
||||
``AgentBackendRunRequestBuilder.build_for_agent_app`` DTO assembler."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from types import SimpleNamespace
|
||||
from typing import Any
|
||||
|
||||
import pytest
|
||||
from dify_agent.layers.execution_context import DifyExecutionContextLayerConfig
|
||||
|
||||
from clients.agent_backend import (
|
||||
AgentBackendAgentAppRunInput,
|
||||
AgentBackendModelConfig,
|
||||
AgentBackendRunRequestBuilder,
|
||||
)
|
||||
from core.app.apps.agent_app.runtime_request_builder import (
|
||||
AgentAppRuntimeBuildContext,
|
||||
AgentAppRuntimeRequestBuilder,
|
||||
AgentAppRuntimeRequestBuildError,
|
||||
)
|
||||
from core.app.entities.app_invoke_entities import InvokeFrom
|
||||
from models.agent_config_entities import AgentSoulConfig
|
||||
|
||||
|
||||
def _exec_ctx() -> DifyExecutionContextLayerConfig:
|
||||
return DifyExecutionContextLayerConfig(tenant_id="tenant-1", invoke_from="agent_app")
|
||||
|
||||
|
||||
class TestBuildForAgentApp:
|
||||
def test_layers_have_no_workflow_job_prompt_and_include_history(self):
|
||||
request = AgentBackendRunRequestBuilder().build_for_agent_app(
|
||||
AgentBackendAgentAppRunInput(
|
||||
model=AgentBackendModelConfig(plugin_id="langgenius/openai", model_provider="openai", model="gpt-test"),
|
||||
execution_context=_exec_ctx(),
|
||||
user_prompt="hello",
|
||||
agent_soul_prompt="You are Iris.",
|
||||
)
|
||||
)
|
||||
names = [layer.name for layer in request.composition.layers]
|
||||
assert names == [
|
||||
"agent_soul_prompt",
|
||||
"agent_app_user_prompt",
|
||||
"execution_context",
|
||||
"history",
|
||||
"llm",
|
||||
]
|
||||
assert "workflow_node_job_prompt" not in names
|
||||
assert request.purpose == "agent_app"
|
||||
# Agent App keeps layers alive across turns by default.
|
||||
assert request.on_exit.default.value == "suspend"
|
||||
|
||||
def test_blank_user_prompt_rejected(self):
|
||||
with pytest.raises(ValueError, match="must not be blank"):
|
||||
AgentBackendAgentAppRunInput(
|
||||
model=AgentBackendModelConfig(plugin_id="p/q", model_provider="openai", model="m"),
|
||||
execution_context=_exec_ctx(),
|
||||
user_prompt=" ",
|
||||
)
|
||||
|
||||
def test_soul_prompt_optional(self):
|
||||
request = AgentBackendRunRequestBuilder().build_for_agent_app(
|
||||
AgentBackendAgentAppRunInput(
|
||||
model=AgentBackendModelConfig(plugin_id="langgenius/openai", model_provider="openai", model="gpt-test"),
|
||||
execution_context=_exec_ctx(),
|
||||
user_prompt="hi",
|
||||
)
|
||||
)
|
||||
assert [layer.name for layer in request.composition.layers][0] == "agent_app_user_prompt"
|
||||
|
||||
|
||||
class _FakeCredentialsProvider:
|
||||
def fetch(self, provider_name: str, model_name: str) -> dict[str, Any]:
|
||||
return {"openai_api_key": "sk-test", "max": 5}
|
||||
|
||||
|
||||
class _NoToolsBuilder:
|
||||
def build(self, **kwargs):
|
||||
del kwargs
|
||||
|
||||
|
||||
def _ctx(soul: AgentSoulConfig, *, query: str = "hello") -> AgentAppRuntimeBuildContext:
|
||||
dify_context = SimpleNamespace(
|
||||
tenant_id="tenant-1",
|
||||
app_id="app-1",
|
||||
user_id="user-1",
|
||||
invoke_from=InvokeFrom.WEB_APP,
|
||||
)
|
||||
return AgentAppRuntimeBuildContext(
|
||||
dify_context=dify_context, # type: ignore[arg-type]
|
||||
agent_id="agent-1",
|
||||
agent_config_snapshot_id="snap-1",
|
||||
agent_soul=soul,
|
||||
conversation_id="conv-1",
|
||||
user_query=query,
|
||||
idempotency_key="msg-1",
|
||||
)
|
||||
|
||||
|
||||
def _soul_with_model() -> AgentSoulConfig:
|
||||
return AgentSoulConfig.model_validate(
|
||||
{
|
||||
"model": {
|
||||
"plugin_id": "langgenius/openai",
|
||||
"model_provider": "langgenius/openai/openai",
|
||||
"model": "gpt-4o-mini",
|
||||
},
|
||||
"prompt": {"system_prompt": "You are Iris."},
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
class TestAgentAppRuntimeRequestBuilder:
|
||||
def test_build_maps_soul_to_run_request(self):
|
||||
builder = AgentAppRuntimeRequestBuilder(
|
||||
credentials_provider=_FakeCredentialsProvider(),
|
||||
plugin_tools_builder=_NoToolsBuilder(), # type: ignore[arg-type]
|
||||
)
|
||||
result = builder.build(_ctx(_soul_with_model()))
|
||||
|
||||
req = result.request
|
||||
assert req.purpose == "agent_app"
|
||||
names = [layer.name for layer in req.composition.layers]
|
||||
assert names == ["agent_soul_prompt", "agent_app_user_prompt", "execution_context", "history", "llm"]
|
||||
# plugin_id / provider normalized for plugin-daemon transport.
|
||||
llm = next(layer for layer in req.composition.layers if layer.name == "llm")
|
||||
assert llm.config.plugin_id == "langgenius/openai"
|
||||
assert llm.config.model_provider == "openai"
|
||||
# execution context carries conversation + agent_app invoke source.
|
||||
exec_ctx = next(layer for layer in req.composition.layers if layer.name == "execution_context")
|
||||
assert exec_ctx.config.conversation_id == "conv-1"
|
||||
assert exec_ctx.config.invoke_from == "agent_app"
|
||||
# credentials are redacted in the log-safe view.
|
||||
assert result.redacted_request["composition"]["layers"][-1]["config"]["credentials"] == "[REDACTED]"
|
||||
assert result.metadata["conversation_id"] == "conv-1"
|
||||
|
||||
def test_build_raises_when_model_missing(self):
|
||||
builder = AgentAppRuntimeRequestBuilder(
|
||||
credentials_provider=_FakeCredentialsProvider(),
|
||||
plugin_tools_builder=_NoToolsBuilder(), # type: ignore[arg-type]
|
||||
)
|
||||
with pytest.raises(AgentAppRuntimeRequestBuildError) as exc:
|
||||
builder.build(_ctx(AgentSoulConfig()))
|
||||
assert exc.value.error_code == "agent_model_not_configured"
|
||||
@ -0,0 +1,115 @@
|
||||
"""Unit tests for the conversation-keyed Agent App session store.
|
||||
|
||||
Exercises the real ORM round-trip against the project's in-memory SQLite engine
|
||||
(per-test create/drop of the unified ``agent_runtime_sessions`` table), so the
|
||||
conversation owner path is verified without Postgres.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Generator
|
||||
|
||||
import pytest
|
||||
from agenton.compositor import CompositorSessionSnapshot
|
||||
from agenton.compositor.schemas import LayerSessionSnapshot
|
||||
from agenton.layers.base import LifecycleState
|
||||
from sqlalchemy import delete
|
||||
|
||||
from core.app.apps.agent_app.session_store import AgentAppRuntimeSessionStore, AgentAppSessionScope
|
||||
from core.db.session_factory import session_factory
|
||||
from models.agent import AgentRuntimeSession, AgentRuntimeSessionOwnerType, AgentRuntimeSessionStatus
|
||||
|
||||
|
||||
def _scope(conversation_id: str = "conv-1", agent_id: str = "agent-1") -> AgentAppSessionScope:
|
||||
return AgentAppSessionScope(
|
||||
tenant_id="tenant-1",
|
||||
app_id="app-1",
|
||||
conversation_id=conversation_id,
|
||||
agent_id=agent_id,
|
||||
)
|
||||
|
||||
|
||||
def _snapshot(messages: int = 1) -> CompositorSessionSnapshot:
|
||||
return CompositorSessionSnapshot(
|
||||
layers=[
|
||||
LayerSessionSnapshot(
|
||||
name="history",
|
||||
lifecycle_state=LifecycleState.SUSPENDED,
|
||||
runtime_state={"messages": [{"role": "user", "content": f"m{i}"} for i in range(messages)]},
|
||||
)
|
||||
]
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _create_table() -> Generator[None, None, None]:
|
||||
engine = session_factory.get_session_maker().kw["bind"]
|
||||
AgentRuntimeSession.__table__.create(bind=engine, checkfirst=True)
|
||||
yield
|
||||
with session_factory.create_session() as session:
|
||||
session.execute(delete(AgentRuntimeSession))
|
||||
session.commit()
|
||||
AgentRuntimeSession.__table__.drop(bind=engine, checkfirst=True)
|
||||
|
||||
|
||||
def test_load_returns_none_when_no_row():
|
||||
assert AgentAppRuntimeSessionStore().load_active_snapshot(_scope()) is None
|
||||
|
||||
|
||||
def test_save_creates_conversation_owned_row_and_round_trips():
|
||||
store = AgentAppRuntimeSessionStore()
|
||||
store.save_active_snapshot(scope=_scope(), backend_run_id="run-1", snapshot=_snapshot(messages=2))
|
||||
|
||||
loaded = store.load_active_snapshot(_scope())
|
||||
assert loaded is not None
|
||||
assert loaded.layers[0].runtime_state["messages"] == [
|
||||
{"role": "user", "content": "m0"},
|
||||
{"role": "user", "content": "m1"},
|
||||
]
|
||||
with session_factory.create_session() as session:
|
||||
row = session.query(AgentRuntimeSession).one()
|
||||
assert row.owner_type == AgentRuntimeSessionOwnerType.CONVERSATION
|
||||
assert row.conversation_id == "conv-1"
|
||||
assert row.workflow_run_id is None # conversation owner leaves workflow cols NULL
|
||||
assert row.backend_run_id == "run-1"
|
||||
|
||||
|
||||
def test_save_is_noop_when_snapshot_missing():
|
||||
store = AgentAppRuntimeSessionStore()
|
||||
store.save_active_snapshot(scope=_scope(), backend_run_id="run-x", snapshot=None)
|
||||
with session_factory.create_session() as session:
|
||||
assert session.query(AgentRuntimeSession).count() == 0
|
||||
|
||||
|
||||
def test_second_turn_updates_same_conversation_row():
|
||||
store = AgentAppRuntimeSessionStore()
|
||||
store.save_active_snapshot(scope=_scope(), backend_run_id="run-1", snapshot=_snapshot(messages=1))
|
||||
store.save_active_snapshot(scope=_scope(), backend_run_id="run-2", snapshot=_snapshot(messages=3))
|
||||
with session_factory.create_session() as session:
|
||||
rows = session.query(AgentRuntimeSession).all()
|
||||
assert len(rows) == 1
|
||||
assert rows[0].backend_run_id == "run-2"
|
||||
|
||||
|
||||
def test_mark_cleaned_then_load_returns_none_and_save_resurrects():
|
||||
store = AgentAppRuntimeSessionStore()
|
||||
store.save_active_snapshot(scope=_scope(), backend_run_id="run-1", snapshot=_snapshot())
|
||||
store.mark_cleaned(scope=_scope(), backend_run_id="cleanup-1")
|
||||
assert store.load_active_snapshot(_scope()) is None
|
||||
# Re-entry revives the row.
|
||||
store.save_active_snapshot(scope=_scope(), backend_run_id="run-2", snapshot=_snapshot(messages=2))
|
||||
with session_factory.create_session() as session:
|
||||
row = session.query(AgentRuntimeSession).one()
|
||||
assert row.status == AgentRuntimeSessionStatus.ACTIVE
|
||||
assert row.cleaned_at is None
|
||||
assert row.backend_run_id == "run-2"
|
||||
|
||||
|
||||
def test_distinct_conversations_do_not_collide():
|
||||
store = AgentAppRuntimeSessionStore()
|
||||
store.save_active_snapshot(scope=_scope(conversation_id="conv-A"), backend_run_id="a", snapshot=_snapshot())
|
||||
store.save_active_snapshot(scope=_scope(conversation_id="conv-B"), backend_run_id="b", snapshot=_snapshot())
|
||||
assert store.load_active_snapshot(_scope(conversation_id="conv-A")) is not None
|
||||
assert store.load_active_snapshot(_scope(conversation_id="conv-B")) is not None
|
||||
with session_factory.create_session() as session:
|
||||
assert session.query(AgentRuntimeSession).count() == 2
|
||||
@ -662,3 +662,55 @@ def test_composer_validator_rejects_stage_4_declared_output_violations():
|
||||
ComposerConfigValidator.validate_node_job_dict(
|
||||
{"declared_outputs": [{"name": "matrix", "type": "array", "array_item": {"type": "array"}}]}
|
||||
)
|
||||
|
||||
|
||||
class TestAgentAppBackingAgent:
|
||||
"""S1: an Agent App (mode=agent) is backed 1:1 by a roster Agent linked via
|
||||
``Agent.app_id``. ``AppService.create_app`` builds the backing agent inside
|
||||
its own transaction, so the helper must add+flush without committing."""
|
||||
|
||||
def test_create_backing_agent_for_app_links_app_and_seeds_default_soul(self):
|
||||
session = FakeSession()
|
||||
service = AgentRosterService(session)
|
||||
|
||||
agent = service.create_backing_agent_for_app(
|
||||
tenant_id="tenant-1",
|
||||
account_id="account-1",
|
||||
app_id="app-1",
|
||||
name="Iris",
|
||||
description="clarifier",
|
||||
)
|
||||
|
||||
# Agent is bound to the app and is a roster/agent_app entry.
|
||||
assert agent.app_id == "app-1"
|
||||
assert agent.scope == AgentScope.ROSTER
|
||||
assert agent.source == AgentSource.AGENT_APP
|
||||
assert agent.status == AgentStatus.ACTIVE
|
||||
assert agent.agent_kind == AgentKind.DIFY_AGENT
|
||||
assert agent.name == "Iris"
|
||||
# A v1 snapshot + revision are seeded and wired as the active version.
|
||||
snapshots = [a for a in session.added if isinstance(a, AgentConfigSnapshot)]
|
||||
assert len(snapshots) == 1
|
||||
assert snapshots[0].version == 1
|
||||
assert agent.active_config_snapshot_id == snapshots[0].id
|
||||
revisions = [
|
||||
a for a in session.added if getattr(a, "operation", None) == AgentConfigRevisionOperation.CREATE_VERSION
|
||||
]
|
||||
assert len(revisions) == 1
|
||||
# Caller (AppService.create_app) owns the commit — helper must not commit.
|
||||
assert session.commits == 0
|
||||
|
||||
def test_get_app_backing_agent_queries_active_agent_app_agent(self):
|
||||
sentinel = SimpleNamespace(id="agent-1", app_id="app-1")
|
||||
session = FakeSession(scalar=[sentinel])
|
||||
service = AgentRosterService(session)
|
||||
|
||||
result = service.get_app_backing_agent(tenant_id="tenant-1", app_id="app-1")
|
||||
|
||||
assert result is sentinel
|
||||
|
||||
def test_get_app_backing_agent_returns_none_when_unbound(self):
|
||||
session = FakeSession()
|
||||
service = AgentRosterService(session)
|
||||
|
||||
assert service.get_app_backing_agent(tenant_id="tenant-1", app_id="app-x") is None
|
||||
|
||||
@ -127,3 +127,31 @@ class TestOpenapiVisibilityHelpers:
|
||||
assert out == rows
|
||||
gate.assert_called_once()
|
||||
mock_session.execute.assert_called_once()
|
||||
|
||||
|
||||
class TestAgentAppType:
|
||||
"""S1: new ``AppMode.AGENT`` app type wiring."""
|
||||
|
||||
def test_agent_mode_enum_and_template_exist(self):
|
||||
from constants.model_template import default_app_templates
|
||||
from models.model import AppMode
|
||||
|
||||
assert AppMode.AGENT.value == "agent"
|
||||
assert AppMode.AGENT in default_app_templates
|
||||
# Runtime config comes from the Agent Soul, so no model_config is seeded.
|
||||
assert "model_config" not in default_app_templates[AppMode.AGENT]
|
||||
assert default_app_templates[AppMode.AGENT]["app"]["mode"] == AppMode.AGENT
|
||||
|
||||
def test_create_app_params_accepts_agent_mode(self):
|
||||
from services.app_service import CreateAppParams
|
||||
|
||||
params = CreateAppParams(name="Iris", mode="agent")
|
||||
assert params.mode == "agent"
|
||||
|
||||
def test_bound_agent_id_is_none_for_non_agent_app(self):
|
||||
"""Non-agent apps short-circuit without touching the DB."""
|
||||
from models.model import App, AppMode
|
||||
|
||||
app = App()
|
||||
app.mode = AppMode.CHAT
|
||||
assert app.bound_agent_id is None
|
||||
|
||||
@ -40,7 +40,7 @@ export type PollSuccess = {
|
||||
subject_type?: string
|
||||
subject_email?: string
|
||||
subject_issuer?: string
|
||||
account?: PollAccount | null
|
||||
account?: PollAccount
|
||||
workspaces?: readonly PollWorkspace[]
|
||||
default_workspace_id?: string
|
||||
token_id?: string
|
||||
|
||||
@ -99,9 +99,8 @@ function renderCodePrompt(w: NodeJS.WritableStream, cs: ReturnType<typeof colorS
|
||||
|
||||
function renderLoggedIn(w: NodeJS.WritableStream, cs: ReturnType<typeof colorScheme>, host: string, s: PollSuccess): void {
|
||||
const display = bareHost(host)
|
||||
const account = s.account ?? undefined
|
||||
if (account !== undefined && account.email !== '') {
|
||||
w.write(`${cs.successIcon()} Logged in to ${display} as ${cs.bold(account.email)} (${account.name})\n`)
|
||||
if (s.account !== undefined && s.account.email !== '') {
|
||||
w.write(`${cs.successIcon()} Logged in to ${display} as ${cs.bold(s.account.email)} (${s.account.name})\n`)
|
||||
const ws = findDefaultWorkspace(s)
|
||||
if (ws !== undefined)
|
||||
w.write(` Workspace: ${ws.name}\n`)
|
||||
@ -140,12 +139,11 @@ function bundleFromSuccess(host: string, s: PollSuccess, mode: StorageMode): Hos
|
||||
token_id: s.token_id,
|
||||
tokens: { bearer: s.token },
|
||||
}
|
||||
const account = s.account ?? undefined
|
||||
if (account !== undefined) {
|
||||
bundle.account = { id: account.id, email: account.email, name: account.name }
|
||||
if (s.account !== undefined) {
|
||||
bundle.account = { id: s.account.id, email: s.account.email, name: s.account.name }
|
||||
}
|
||||
if (s.subject_email !== undefined && s.subject_email !== ''
|
||||
&& (account === undefined || account.id === '')) {
|
||||
&& (s.account === undefined || s.account.id === '')) {
|
||||
bundle.external_subject = {
|
||||
email: s.subject_email,
|
||||
issuer: s.subject_issuer ?? '',
|
||||
|
||||
@ -44,7 +44,7 @@ abstract class FileBasedStore implements Store {
|
||||
flush(): void {
|
||||
fs.mkdirSync(dirname(this.filePath), { recursive: true, mode: DIR_PERM })
|
||||
|
||||
// we don't handle A-B-A scenario,
|
||||
// we don't handle A-B-A scenario,
|
||||
// which is not likely to happen in cli
|
||||
if (!this.dirty) {
|
||||
return
|
||||
|
||||
3
cli/test/fixtures/dify-mock/server.ts
vendored
3
cli/test/fixtures/dify-mock/server.ts
vendored
@ -356,9 +356,6 @@ export function buildApp(getScenario: () => Scenario, state?: MockState): Hono {
|
||||
subject_type: 'external_sso',
|
||||
subject_email: 'sso@dify.ai',
|
||||
subject_issuer: 'https://issuer.example',
|
||||
account: null,
|
||||
workspaces: [],
|
||||
default_workspace_id: null,
|
||||
token_id: 'tok-sso-1',
|
||||
})
|
||||
}
|
||||
|
||||
@ -12,7 +12,9 @@ import {
|
||||
zGetAgentsByAgentIdVersionsByVersionIdResponse,
|
||||
zGetAgentsByAgentIdVersionsPath,
|
||||
zGetAgentsByAgentIdVersionsResponse,
|
||||
zGetAgentsInviteOptionsQuery,
|
||||
zGetAgentsInviteOptionsResponse,
|
||||
zGetAgentsQuery,
|
||||
zGetAgentsResponse,
|
||||
zPatchAgentsByAgentIdBody,
|
||||
zPatchAgentsByAgentIdPath,
|
||||
@ -21,22 +23,15 @@ import {
|
||||
zPostAgentsResponse,
|
||||
} from './zod.gen'
|
||||
|
||||
/**
|
||||
* Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.
|
||||
*
|
||||
* @deprecated
|
||||
*/
|
||||
export const get = oc
|
||||
.route({
|
||||
deprecated: true,
|
||||
description:
|
||||
'Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.',
|
||||
inputStructure: 'detailed',
|
||||
method: 'GET',
|
||||
operationId: 'getAgentsInviteOptions',
|
||||
path: '/agents/invite-options',
|
||||
tags: ['console'],
|
||||
})
|
||||
.input(z.object({ query: zGetAgentsInviteOptionsQuery.optional() }))
|
||||
.output(zGetAgentsInviteOptionsResponse)
|
||||
|
||||
export const inviteOptions = {
|
||||
@ -66,16 +61,8 @@ export const byVersionId = {
|
||||
get: get2,
|
||||
}
|
||||
|
||||
/**
|
||||
* Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.
|
||||
*
|
||||
* @deprecated
|
||||
*/
|
||||
export const get3 = oc
|
||||
.route({
|
||||
deprecated: true,
|
||||
description:
|
||||
'Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.',
|
||||
inputStructure: 'detailed',
|
||||
method: 'GET',
|
||||
operationId: 'getAgentsByAgentIdVersions',
|
||||
@ -90,35 +77,20 @@ export const versions = {
|
||||
byVersionId,
|
||||
}
|
||||
|
||||
/**
|
||||
* Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.
|
||||
*
|
||||
* @deprecated
|
||||
*/
|
||||
export const delete_ = oc
|
||||
.route({
|
||||
deprecated: true,
|
||||
description:
|
||||
'Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.',
|
||||
inputStructure: 'detailed',
|
||||
method: 'DELETE',
|
||||
operationId: 'deleteAgentsByAgentId',
|
||||
path: '/agents/{agent_id}',
|
||||
successStatus: 204,
|
||||
tags: ['console'],
|
||||
})
|
||||
.input(z.object({ params: zDeleteAgentsByAgentIdPath }))
|
||||
.output(zDeleteAgentsByAgentIdResponse)
|
||||
|
||||
/**
|
||||
* Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.
|
||||
*
|
||||
* @deprecated
|
||||
*/
|
||||
export const get4 = oc
|
||||
.route({
|
||||
deprecated: true,
|
||||
description:
|
||||
'Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.',
|
||||
inputStructure: 'detailed',
|
||||
method: 'GET',
|
||||
operationId: 'getAgentsByAgentId',
|
||||
@ -128,16 +100,8 @@ export const get4 = oc
|
||||
.input(z.object({ params: zGetAgentsByAgentIdPath }))
|
||||
.output(zGetAgentsByAgentIdResponse)
|
||||
|
||||
/**
|
||||
* Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.
|
||||
*
|
||||
* @deprecated
|
||||
*/
|
||||
export const patch = oc
|
||||
.route({
|
||||
deprecated: true,
|
||||
description:
|
||||
'Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.',
|
||||
inputStructure: 'detailed',
|
||||
method: 'PATCH',
|
||||
operationId: 'patchAgentsByAgentId',
|
||||
@ -154,22 +118,15 @@ export const byAgentId = {
|
||||
versions,
|
||||
}
|
||||
|
||||
/**
|
||||
* Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.
|
||||
*
|
||||
* @deprecated
|
||||
*/
|
||||
export const get5 = oc
|
||||
.route({
|
||||
deprecated: true,
|
||||
description:
|
||||
'Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.',
|
||||
inputStructure: 'detailed',
|
||||
method: 'GET',
|
||||
operationId: 'getAgents',
|
||||
path: '/agents',
|
||||
tags: ['console'],
|
||||
})
|
||||
.input(z.object({ query: zGetAgentsQuery.optional() }))
|
||||
.output(zGetAgentsResponse)
|
||||
|
||||
/**
|
||||
@ -186,6 +143,7 @@ export const post = oc
|
||||
method: 'POST',
|
||||
operationId: 'postAgents',
|
||||
path: '/agents',
|
||||
successStatus: 201,
|
||||
tags: ['console'],
|
||||
})
|
||||
.input(z.object({ body: zPostAgentsBody }))
|
||||
|
||||
@ -4,6 +4,14 @@ export type ClientOptions = {
|
||||
baseUrl: `${string}://${string}/console/api` | (string & {})
|
||||
}
|
||||
|
||||
export type AgentRosterListResponse = {
|
||||
data: Array<AgentRosterResponse>
|
||||
has_more: boolean
|
||||
limit: number
|
||||
page: number
|
||||
total: number
|
||||
}
|
||||
|
||||
export type RosterAgentCreatePayload = {
|
||||
agent_soul?: AgentSoulConfig
|
||||
description?: string
|
||||
@ -14,6 +22,38 @@ export type RosterAgentCreatePayload = {
|
||||
version_note?: string | null
|
||||
}
|
||||
|
||||
export type AgentRosterResponse = {
|
||||
active_config_snapshot?: AgentConfigSnapshotSummaryResponse
|
||||
active_config_snapshot_id?: string | null
|
||||
agent_kind: AgentKind
|
||||
app_id?: string | null
|
||||
archived_at?: string | null
|
||||
archived_by?: string | null
|
||||
created_at?: string | null
|
||||
created_by?: string | null
|
||||
description: string
|
||||
icon?: string | null
|
||||
icon_background?: string | null
|
||||
icon_type?: AgentIconType
|
||||
id: string
|
||||
name: string
|
||||
scope: AgentScope
|
||||
source: AgentSource
|
||||
status: AgentStatus
|
||||
updated_at?: string | null
|
||||
updated_by?: string | null
|
||||
workflow_id?: string | null
|
||||
workflow_node_id?: string | null
|
||||
}
|
||||
|
||||
export type AgentInviteOptionsResponse = {
|
||||
data: Array<AgentInviteOptionResponse>
|
||||
has_more: boolean
|
||||
limit: number
|
||||
page: number
|
||||
total: number
|
||||
}
|
||||
|
||||
export type RosterAgentUpdatePayload = {
|
||||
description?: string | null
|
||||
icon?: string | null
|
||||
@ -22,6 +62,22 @@ export type RosterAgentUpdatePayload = {
|
||||
name?: string | null
|
||||
}
|
||||
|
||||
export type AgentConfigSnapshotListResponse = {
|
||||
data: Array<AgentConfigSnapshotSummaryResponse>
|
||||
}
|
||||
|
||||
export type AgentConfigSnapshotDetailResponse = {
|
||||
agent_id?: string | null
|
||||
config_snapshot: AgentSoulConfig
|
||||
created_at?: string | null
|
||||
created_by?: string | null
|
||||
id: string
|
||||
revisions?: Array<AgentConfigRevisionResponse>
|
||||
summary?: string | null
|
||||
version: number
|
||||
version_note?: string | null
|
||||
}
|
||||
|
||||
export type AgentSoulConfig = {
|
||||
app_features?: {
|
||||
[key: string]: unknown
|
||||
@ -44,6 +100,63 @@ export type AgentSoulConfig = {
|
||||
|
||||
export type AgentIconType = 'emoji' | 'image' | 'link'
|
||||
|
||||
export type AgentConfigSnapshotSummaryResponse = {
|
||||
agent_id?: string | null
|
||||
created_at?: string | null
|
||||
created_by?: string | null
|
||||
id: string
|
||||
summary?: string | null
|
||||
version: number
|
||||
version_note?: string | null
|
||||
}
|
||||
|
||||
export type AgentKind = 'dify_agent'
|
||||
|
||||
export type AgentScope = 'roster' | 'workflow_only'
|
||||
|
||||
export type AgentSource = 'agent_app' | 'imported' | 'system' | 'workflow'
|
||||
|
||||
export type AgentStatus = 'active' | 'archived'
|
||||
|
||||
export type AgentInviteOptionResponse = {
|
||||
active_config_snapshot?: AgentConfigSnapshotSummaryResponse
|
||||
active_config_snapshot_id?: string | null
|
||||
agent_kind: AgentKind
|
||||
app_id?: string | null
|
||||
archived_at?: string | null
|
||||
archived_by?: string | null
|
||||
created_at?: string | null
|
||||
created_by?: string | null
|
||||
description: string
|
||||
existing_node_ids?: Array<string>
|
||||
icon?: string | null
|
||||
icon_background?: string | null
|
||||
icon_type?: AgentIconType
|
||||
id: string
|
||||
in_current_workflow_count?: number
|
||||
is_in_current_workflow?: boolean
|
||||
name: string
|
||||
scope: AgentScope
|
||||
source: AgentSource
|
||||
status: AgentStatus
|
||||
updated_at?: string | null
|
||||
updated_by?: string | null
|
||||
workflow_id?: string | null
|
||||
workflow_node_id?: string | null
|
||||
}
|
||||
|
||||
export type AgentConfigRevisionResponse = {
|
||||
created_at?: string | null
|
||||
created_by?: string | null
|
||||
current_snapshot_id: string
|
||||
id: string
|
||||
operation: AgentConfigRevisionOperation
|
||||
previous_snapshot_id?: string | null
|
||||
revision: number
|
||||
summary?: string | null
|
||||
version_note?: string | null
|
||||
}
|
||||
|
||||
export type AppVariableConfig = {
|
||||
default?: unknown
|
||||
name: string
|
||||
@ -124,6 +237,13 @@ export type AgentSoulToolsConfig = {
|
||||
dify_tools?: Array<AgentSoulDifyToolConfig>
|
||||
}
|
||||
|
||||
export type AgentConfigRevisionOperation
|
||||
= | 'create_version'
|
||||
| 'save_current_version'
|
||||
| 'save_new_agent'
|
||||
| 'save_new_version'
|
||||
| 'save_to_roster'
|
||||
|
||||
export type AgentKnowledgeQueryMode = 'generated_query' | 'user_query'
|
||||
|
||||
export type AgentSoulModelCredentialRef = {
|
||||
@ -157,14 +277,16 @@ export type AgentSoulDifyToolCredentialRef = {
|
||||
export type GetAgentsData = {
|
||||
body?: never
|
||||
path?: never
|
||||
query?: never
|
||||
query?: {
|
||||
keyword?: string
|
||||
limit?: number
|
||||
page?: number
|
||||
}
|
||||
url: '/agents'
|
||||
}
|
||||
|
||||
export type GetAgentsResponses = {
|
||||
200: {
|
||||
[key: string]: unknown
|
||||
}
|
||||
200: AgentRosterListResponse
|
||||
}
|
||||
|
||||
export type GetAgentsResponse = GetAgentsResponses[keyof GetAgentsResponses]
|
||||
@ -177,9 +299,7 @@ export type PostAgentsData = {
|
||||
}
|
||||
|
||||
export type PostAgentsResponses = {
|
||||
200: {
|
||||
[key: string]: unknown
|
||||
}
|
||||
201: AgentRosterResponse
|
||||
}
|
||||
|
||||
export type PostAgentsResponse = PostAgentsResponses[keyof PostAgentsResponses]
|
||||
@ -187,14 +307,17 @@ export type PostAgentsResponse = PostAgentsResponses[keyof PostAgentsResponses]
|
||||
export type GetAgentsInviteOptionsData = {
|
||||
body?: never
|
||||
path?: never
|
||||
query?: never
|
||||
query?: {
|
||||
app_id?: string
|
||||
keyword?: string
|
||||
limit?: number
|
||||
page?: number
|
||||
}
|
||||
url: '/agents/invite-options'
|
||||
}
|
||||
|
||||
export type GetAgentsInviteOptionsResponses = {
|
||||
200: {
|
||||
[key: string]: unknown
|
||||
}
|
||||
200: AgentInviteOptionsResponse
|
||||
}
|
||||
|
||||
export type GetAgentsInviteOptionsResponse
|
||||
@ -210,8 +333,8 @@ export type DeleteAgentsByAgentIdData = {
|
||||
}
|
||||
|
||||
export type DeleteAgentsByAgentIdResponses = {
|
||||
200: {
|
||||
[key: string]: unknown
|
||||
204: {
|
||||
[key: string]: never
|
||||
}
|
||||
}
|
||||
|
||||
@ -228,9 +351,7 @@ export type GetAgentsByAgentIdData = {
|
||||
}
|
||||
|
||||
export type GetAgentsByAgentIdResponses = {
|
||||
200: {
|
||||
[key: string]: unknown
|
||||
}
|
||||
200: AgentRosterResponse
|
||||
}
|
||||
|
||||
export type GetAgentsByAgentIdResponse
|
||||
@ -246,9 +367,7 @@ export type PatchAgentsByAgentIdData = {
|
||||
}
|
||||
|
||||
export type PatchAgentsByAgentIdResponses = {
|
||||
200: {
|
||||
[key: string]: unknown
|
||||
}
|
||||
200: AgentRosterResponse
|
||||
}
|
||||
|
||||
export type PatchAgentsByAgentIdResponse
|
||||
@ -264,9 +383,7 @@ export type GetAgentsByAgentIdVersionsData = {
|
||||
}
|
||||
|
||||
export type GetAgentsByAgentIdVersionsResponses = {
|
||||
200: {
|
||||
[key: string]: unknown
|
||||
}
|
||||
200: AgentConfigSnapshotListResponse
|
||||
}
|
||||
|
||||
export type GetAgentsByAgentIdVersionsResponse
|
||||
@ -283,9 +400,7 @@ export type GetAgentsByAgentIdVersionsByVersionIdData = {
|
||||
}
|
||||
|
||||
export type GetAgentsByAgentIdVersionsByVersionIdResponses = {
|
||||
200: {
|
||||
[key: string]: unknown
|
||||
}
|
||||
200: AgentConfigSnapshotDetailResponse
|
||||
}
|
||||
|
||||
export type GetAgentsByAgentIdVersionsByVersionIdResponse
|
||||
|
||||
@ -20,6 +20,136 @@ export const zRosterAgentUpdatePayload = z.object({
|
||||
name: z.string().min(1).max(255).nullish(),
|
||||
})
|
||||
|
||||
/**
|
||||
* AgentConfigSnapshotSummaryResponse
|
||||
*/
|
||||
export const zAgentConfigSnapshotSummaryResponse = z.object({
|
||||
agent_id: z.string().nullish(),
|
||||
created_at: z.string().nullish(),
|
||||
created_by: z.string().nullish(),
|
||||
id: z.string(),
|
||||
summary: z.string().nullish(),
|
||||
version: z.int(),
|
||||
version_note: z.string().nullish(),
|
||||
})
|
||||
|
||||
/**
|
||||
* AgentConfigSnapshotListResponse
|
||||
*/
|
||||
export const zAgentConfigSnapshotListResponse = z.object({
|
||||
data: z.array(zAgentConfigSnapshotSummaryResponse),
|
||||
})
|
||||
|
||||
/**
|
||||
* AgentKind
|
||||
*
|
||||
* Agent implementation family.
|
||||
*
|
||||
* This leaves room for future non-Dify agent implementations while keeping
|
||||
* the current roster/workflow APIs scoped to Dify Agent.
|
||||
*/
|
||||
export const zAgentKind = z.enum(['dify_agent'])
|
||||
|
||||
/**
|
||||
* AgentScope
|
||||
*
|
||||
* Visibility and lifecycle scope of an Agent record.
|
||||
*/
|
||||
export const zAgentScope = z.enum(['roster', 'workflow_only'])
|
||||
|
||||
/**
|
||||
* AgentSource
|
||||
*
|
||||
* Origin that created or imported the Agent.
|
||||
*/
|
||||
export const zAgentSource = z.enum(['agent_app', 'imported', 'system', 'workflow'])
|
||||
|
||||
/**
|
||||
* AgentStatus
|
||||
*
|
||||
* Soft lifecycle state for Agent records.
|
||||
*/
|
||||
export const zAgentStatus = z.enum(['active', 'archived'])
|
||||
|
||||
/**
|
||||
* AgentRosterResponse
|
||||
*/
|
||||
export const zAgentRosterResponse = z.object({
|
||||
active_config_snapshot: zAgentConfigSnapshotSummaryResponse.optional(),
|
||||
active_config_snapshot_id: z.string().nullish(),
|
||||
agent_kind: zAgentKind,
|
||||
app_id: z.string().nullish(),
|
||||
archived_at: z.string().nullish(),
|
||||
archived_by: z.string().nullish(),
|
||||
created_at: z.string().nullish(),
|
||||
created_by: z.string().nullish(),
|
||||
description: z.string(),
|
||||
icon: z.string().nullish(),
|
||||
icon_background: z.string().nullish(),
|
||||
icon_type: zAgentIconType.optional(),
|
||||
id: z.string(),
|
||||
name: z.string(),
|
||||
scope: zAgentScope,
|
||||
source: zAgentSource,
|
||||
status: zAgentStatus,
|
||||
updated_at: z.string().nullish(),
|
||||
updated_by: z.string().nullish(),
|
||||
workflow_id: z.string().nullish(),
|
||||
workflow_node_id: z.string().nullish(),
|
||||
})
|
||||
|
||||
/**
|
||||
* AgentRosterListResponse
|
||||
*/
|
||||
export const zAgentRosterListResponse = z.object({
|
||||
data: z.array(zAgentRosterResponse),
|
||||
has_more: z.boolean(),
|
||||
limit: z.int(),
|
||||
page: z.int(),
|
||||
total: z.int(),
|
||||
})
|
||||
|
||||
/**
|
||||
* AgentInviteOptionResponse
|
||||
*/
|
||||
export const zAgentInviteOptionResponse = z.object({
|
||||
active_config_snapshot: zAgentConfigSnapshotSummaryResponse.optional(),
|
||||
active_config_snapshot_id: z.string().nullish(),
|
||||
agent_kind: zAgentKind,
|
||||
app_id: z.string().nullish(),
|
||||
archived_at: z.string().nullish(),
|
||||
archived_by: z.string().nullish(),
|
||||
created_at: z.string().nullish(),
|
||||
created_by: z.string().nullish(),
|
||||
description: z.string(),
|
||||
existing_node_ids: z.array(z.string()).optional(),
|
||||
icon: z.string().nullish(),
|
||||
icon_background: z.string().nullish(),
|
||||
icon_type: zAgentIconType.optional(),
|
||||
id: z.string(),
|
||||
in_current_workflow_count: z.int().optional().default(0),
|
||||
is_in_current_workflow: z.boolean().optional().default(false),
|
||||
name: z.string(),
|
||||
scope: zAgentScope,
|
||||
source: zAgentSource,
|
||||
status: zAgentStatus,
|
||||
updated_at: z.string().nullish(),
|
||||
updated_by: z.string().nullish(),
|
||||
workflow_id: z.string().nullish(),
|
||||
workflow_node_id: z.string().nullish(),
|
||||
})
|
||||
|
||||
/**
|
||||
* AgentInviteOptionsResponse
|
||||
*/
|
||||
export const zAgentInviteOptionsResponse = z.object({
|
||||
data: z.array(zAgentInviteOptionResponse),
|
||||
has_more: z.boolean(),
|
||||
limit: z.int(),
|
||||
page: z.int(),
|
||||
total: z.int(),
|
||||
})
|
||||
|
||||
/**
|
||||
* AppVariableConfig
|
||||
*/
|
||||
@ -78,6 +208,34 @@ export const zAgentSoulSkillsFilesConfig = z.object({
|
||||
skills: z.array(z.record(z.string(), z.unknown())).optional(),
|
||||
})
|
||||
|
||||
/**
|
||||
* AgentConfigRevisionOperation
|
||||
*
|
||||
* Audit operation recorded for Agent Soul version/revision changes.
|
||||
*/
|
||||
export const zAgentConfigRevisionOperation = z.enum([
|
||||
'create_version',
|
||||
'save_current_version',
|
||||
'save_new_agent',
|
||||
'save_new_version',
|
||||
'save_to_roster',
|
||||
])
|
||||
|
||||
/**
|
||||
* AgentConfigRevisionResponse
|
||||
*/
|
||||
export const zAgentConfigRevisionResponse = z.object({
|
||||
created_at: z.string().nullish(),
|
||||
created_by: z.string().nullish(),
|
||||
current_snapshot_id: z.string(),
|
||||
id: z.string(),
|
||||
operation: zAgentConfigRevisionOperation,
|
||||
previous_snapshot_id: z.string().nullish(),
|
||||
revision: z.int(),
|
||||
summary: z.string().nullish(),
|
||||
version_note: z.string().nullish(),
|
||||
})
|
||||
|
||||
/**
|
||||
* AgentKnowledgeQueryMode
|
||||
*/
|
||||
@ -196,39 +354,67 @@ export const zRosterAgentCreatePayload = z.object({
|
||||
})
|
||||
|
||||
/**
|
||||
* Success
|
||||
* AgentConfigSnapshotDetailResponse
|
||||
*/
|
||||
export const zGetAgentsResponse = z.record(z.string(), z.unknown())
|
||||
export const zAgentConfigSnapshotDetailResponse = z.object({
|
||||
agent_id: z.string().nullish(),
|
||||
config_snapshot: zAgentSoulConfig,
|
||||
created_at: z.string().nullish(),
|
||||
created_by: z.string().nullish(),
|
||||
id: z.string(),
|
||||
revisions: z.array(zAgentConfigRevisionResponse).optional(),
|
||||
summary: z.string().nullish(),
|
||||
version: z.int(),
|
||||
version_note: z.string().nullish(),
|
||||
})
|
||||
|
||||
export const zGetAgentsQuery = z.object({
|
||||
keyword: z.string().optional(),
|
||||
limit: z.int().gte(1).lte(100).optional().default(20),
|
||||
page: z.int().gte(1).optional().default(1),
|
||||
})
|
||||
|
||||
/**
|
||||
* Agent roster list
|
||||
*/
|
||||
export const zGetAgentsResponse = zAgentRosterListResponse
|
||||
|
||||
export const zPostAgentsBody = zRosterAgentCreatePayload
|
||||
|
||||
/**
|
||||
* Success
|
||||
* Agent created
|
||||
*/
|
||||
export const zPostAgentsResponse = z.record(z.string(), z.unknown())
|
||||
export const zPostAgentsResponse = zAgentRosterResponse
|
||||
|
||||
export const zGetAgentsInviteOptionsQuery = z.object({
|
||||
app_id: z.string().optional(),
|
||||
keyword: z.string().optional(),
|
||||
limit: z.int().gte(1).lte(100).optional().default(20),
|
||||
page: z.int().gte(1).optional().default(1),
|
||||
})
|
||||
|
||||
/**
|
||||
* Success
|
||||
* Agent invite options
|
||||
*/
|
||||
export const zGetAgentsInviteOptionsResponse = z.record(z.string(), z.unknown())
|
||||
export const zGetAgentsInviteOptionsResponse = zAgentInviteOptionsResponse
|
||||
|
||||
export const zDeleteAgentsByAgentIdPath = z.object({
|
||||
agent_id: z.string(),
|
||||
})
|
||||
|
||||
/**
|
||||
* Success
|
||||
* Agent archived
|
||||
*/
|
||||
export const zDeleteAgentsByAgentIdResponse = z.record(z.string(), z.unknown())
|
||||
export const zDeleteAgentsByAgentIdResponse = z.record(z.string(), z.never())
|
||||
|
||||
export const zGetAgentsByAgentIdPath = z.object({
|
||||
agent_id: z.string(),
|
||||
})
|
||||
|
||||
/**
|
||||
* Success
|
||||
* Agent detail
|
||||
*/
|
||||
export const zGetAgentsByAgentIdResponse = z.record(z.string(), z.unknown())
|
||||
export const zGetAgentsByAgentIdResponse = zAgentRosterResponse
|
||||
|
||||
export const zPatchAgentsByAgentIdBody = zRosterAgentUpdatePayload
|
||||
|
||||
@ -237,18 +423,18 @@ export const zPatchAgentsByAgentIdPath = z.object({
|
||||
})
|
||||
|
||||
/**
|
||||
* Success
|
||||
* Agent updated
|
||||
*/
|
||||
export const zPatchAgentsByAgentIdResponse = z.record(z.string(), z.unknown())
|
||||
export const zPatchAgentsByAgentIdResponse = zAgentRosterResponse
|
||||
|
||||
export const zGetAgentsByAgentIdVersionsPath = z.object({
|
||||
agent_id: z.string(),
|
||||
})
|
||||
|
||||
/**
|
||||
* Success
|
||||
* Agent versions
|
||||
*/
|
||||
export const zGetAgentsByAgentIdVersionsResponse = z.record(z.string(), z.unknown())
|
||||
export const zGetAgentsByAgentIdVersionsResponse = zAgentConfigSnapshotListResponse
|
||||
|
||||
export const zGetAgentsByAgentIdVersionsByVersionIdPath = z.object({
|
||||
agent_id: z.string(),
|
||||
@ -256,6 +442,6 @@ export const zGetAgentsByAgentIdVersionsByVersionIdPath = z.object({
|
||||
})
|
||||
|
||||
/**
|
||||
* Success
|
||||
* Agent version detail
|
||||
*/
|
||||
export const zGetAgentsByAgentIdVersionsByVersionIdResponse = z.record(z.string(), z.unknown())
|
||||
export const zGetAgentsByAgentIdVersionsByVersionIdResponse = zAgentConfigSnapshotDetailResponse
|
||||
|
||||
@ -352,6 +352,7 @@ import {
|
||||
zPostAppsByAppIdWorkflowsDraftLoopNodesByNodeIdRunBody,
|
||||
zPostAppsByAppIdWorkflowsDraftLoopNodesByNodeIdRunPath,
|
||||
zPostAppsByAppIdWorkflowsDraftLoopNodesByNodeIdRunResponse,
|
||||
zPostAppsByAppIdWorkflowsDraftNodesByNodeIdAgentComposerImpactBody,
|
||||
zPostAppsByAppIdWorkflowsDraftNodesByNodeIdAgentComposerImpactPath,
|
||||
zPostAppsByAppIdWorkflowsDraftNodesByNodeIdAgentComposerImpactResponse,
|
||||
zPostAppsByAppIdWorkflowsDraftNodesByNodeIdAgentComposerSaveToRosterBody,
|
||||
@ -3516,7 +3517,12 @@ export const post47 = oc
|
||||
path: '/apps/{app_id}/workflows/draft/nodes/{node_id}/agent-composer/impact',
|
||||
tags: ['console'],
|
||||
})
|
||||
.input(z.object({ params: zPostAppsByAppIdWorkflowsDraftNodesByNodeIdAgentComposerImpactPath }))
|
||||
.input(
|
||||
z.object({
|
||||
body: zPostAppsByAppIdWorkflowsDraftNodesByNodeIdAgentComposerImpactBody,
|
||||
params: zPostAppsByAppIdWorkflowsDraftNodesByNodeIdAgentComposerImpactPath,
|
||||
}),
|
||||
)
|
||||
.output(zPostAppsByAppIdWorkflowsDraftNodesByNodeIdAgentComposerImpactResponse)
|
||||
|
||||
export const impact = {
|
||||
|
||||
@ -80,6 +80,7 @@ export type AppDetailWithSite = {
|
||||
access_mode?: string | null
|
||||
api_base_url?: string | null
|
||||
app_model_config?: ModelConfig
|
||||
bound_agent_id?: string | null
|
||||
created_at?: number | null
|
||||
created_by?: string | null
|
||||
deleted_tools?: Array<DeletedTool>
|
||||
@ -167,6 +168,14 @@ export type AdvancedChatWorkflowRunPayload = {
|
||||
query?: string
|
||||
}
|
||||
|
||||
export type AgentAppComposerResponse = {
|
||||
active_config_snapshot: AgentConfigSnapshotSummaryResponse
|
||||
agent: AgentComposerAgentResponse
|
||||
agent_soul: AgentSoulConfig
|
||||
save_options: Array<ComposerSaveStrategy>
|
||||
variant: string
|
||||
}
|
||||
|
||||
export type ComposerSavePayload = {
|
||||
agent_soul?: AgentSoulConfig
|
||||
binding?: ComposerBindingPayload
|
||||
@ -180,6 +189,18 @@ export type ComposerSavePayload = {
|
||||
version_note?: string | null
|
||||
}
|
||||
|
||||
export type AgentComposerCandidatesResponse = {
|
||||
allowed_node_job_candidates?: AgentComposerNodeJobCandidatesResponse
|
||||
allowed_soul_candidates?: AgentComposerSoulCandidatesResponse
|
||||
capabilities?: ComposerCandidateCapabilities
|
||||
variant: ComposerVariant
|
||||
}
|
||||
|
||||
export type AgentComposerValidateResponse = {
|
||||
errors?: Array<string>
|
||||
result: string
|
||||
}
|
||||
|
||||
export type AnnotationReplyPayload = {
|
||||
embedding_model_name: string
|
||||
embedding_provider_name: string
|
||||
@ -303,7 +324,7 @@ export type CompletionMessagePayload = {
|
||||
inputs: {
|
||||
[key: string]: unknown
|
||||
}
|
||||
model_config: {
|
||||
model_config?: {
|
||||
[key: string]: unknown
|
||||
}
|
||||
query?: string
|
||||
@ -736,6 +757,28 @@ export type HumanInputDeliveryTestPayload = {
|
||||
}
|
||||
}
|
||||
|
||||
export type WorkflowAgentComposerResponse = {
|
||||
active_config_snapshot?: AgentConfigSnapshotSummaryResponse
|
||||
agent?: AgentComposerAgentResponse
|
||||
agent_soul: AgentSoulConfig
|
||||
app_id?: string | null
|
||||
binding?: AgentComposerBindingResponse
|
||||
effective_declared_outputs?: Array<DeclaredOutputConfig>
|
||||
impact_summary?: AgentComposerImpactResponse
|
||||
node_id?: string | null
|
||||
node_job: WorkflowNodeJobConfig
|
||||
save_options: Array<ComposerSaveStrategy>
|
||||
soul_lock: AgentComposerSoulLockResponse
|
||||
variant: string
|
||||
workflow_id?: string | null
|
||||
}
|
||||
|
||||
export type AgentComposerImpactResponse = {
|
||||
bindings?: Array<AgentComposerImpactBindingResponse>
|
||||
current_snapshot_id?: string | null
|
||||
workflow_node_count: number
|
||||
}
|
||||
|
||||
export type WorkflowRunNodeExecutionResponse = {
|
||||
created_at?: number | null
|
||||
created_by_account?: SimpleAccount
|
||||
@ -961,6 +1004,25 @@ export type AdvancedChatWorkflowRunForListResponse = {
|
||||
version?: string | null
|
||||
}
|
||||
|
||||
export type AgentConfigSnapshotSummaryResponse = {
|
||||
agent_id?: string | null
|
||||
created_at?: string | null
|
||||
created_by?: string | null
|
||||
id: string
|
||||
summary?: string | null
|
||||
version: number
|
||||
version_note?: string | null
|
||||
}
|
||||
|
||||
export type AgentComposerAgentResponse = {
|
||||
active_config_snapshot_id?: string | null
|
||||
description: string
|
||||
id: string
|
||||
name: string
|
||||
scope: AgentScope
|
||||
status: AgentStatus
|
||||
}
|
||||
|
||||
export type AgentSoulConfig = {
|
||||
app_features?: {
|
||||
[key: string]: unknown
|
||||
@ -981,6 +1043,13 @@ export type AgentSoulConfig = {
|
||||
tools?: AgentSoulToolsConfig
|
||||
}
|
||||
|
||||
export type ComposerSaveStrategy
|
||||
= | 'node_job_only'
|
||||
| 'save_as_new_agent'
|
||||
| 'save_as_new_version'
|
||||
| 'save_to_current_version'
|
||||
| 'save_to_roster'
|
||||
|
||||
export type ComposerBindingPayload = {
|
||||
agent_id?: string | null
|
||||
binding_type: 'inline_agent' | 'roster_agent'
|
||||
@ -1003,13 +1072,6 @@ export type WorkflowNodeJobConfig = {
|
||||
workflow_prompt?: string
|
||||
}
|
||||
|
||||
export type ComposerSaveStrategy
|
||||
= | 'node_job_only'
|
||||
| 'save_as_new_agent'
|
||||
| 'save_as_new_version'
|
||||
| 'save_to_current_version'
|
||||
| 'save_to_roster'
|
||||
|
||||
export type ComposerSoulLockPayload = {
|
||||
locked?: boolean
|
||||
unlocked_from_version_id?: string | null
|
||||
@ -1017,6 +1079,38 @@ export type ComposerSoulLockPayload = {
|
||||
|
||||
export type ComposerVariant = 'agent_app' | 'workflow'
|
||||
|
||||
export type AgentComposerNodeJobCandidatesResponse = {
|
||||
declare_output_types?: Array<DeclaredOutputType>
|
||||
human_contacts?: Array<{
|
||||
[key: string]: unknown
|
||||
}>
|
||||
previous_node_outputs?: Array<{
|
||||
[key: string]: unknown
|
||||
}>
|
||||
}
|
||||
|
||||
export type AgentComposerSoulCandidatesResponse = {
|
||||
cli_tools?: Array<{
|
||||
[key: string]: unknown
|
||||
}>
|
||||
dify_tools?: Array<{
|
||||
[key: string]: unknown
|
||||
}>
|
||||
human_contacts?: Array<{
|
||||
[key: string]: unknown
|
||||
}>
|
||||
knowledge_datasets?: Array<{
|
||||
[key: string]: unknown
|
||||
}>
|
||||
skills_files?: Array<{
|
||||
[key: string]: unknown
|
||||
}>
|
||||
}
|
||||
|
||||
export type ComposerCandidateCapabilities = {
|
||||
human_roster_available?: boolean
|
||||
}
|
||||
|
||||
export type AnnotationHitHistory = {
|
||||
annotation_content?: string | null
|
||||
annotation_question?: string | null
|
||||
@ -1305,6 +1399,39 @@ export type PipelineVariableResponse = {
|
||||
variable: string
|
||||
}
|
||||
|
||||
export type AgentComposerBindingResponse = {
|
||||
agent_id?: string | null
|
||||
binding_type: WorkflowAgentBindingType
|
||||
current_snapshot_id?: string | null
|
||||
id: string
|
||||
node_id: string
|
||||
workflow_id: string
|
||||
}
|
||||
|
||||
export type DeclaredOutputConfig = {
|
||||
array_item?: DeclaredArrayItem
|
||||
check?: DeclaredOutputCheckConfig
|
||||
description?: string | null
|
||||
failure_strategy?: DeclaredOutputFailureStrategy
|
||||
file?: DeclaredOutputFileConfig
|
||||
id?: string | null
|
||||
name: string
|
||||
required?: boolean
|
||||
type: DeclaredOutputType
|
||||
}
|
||||
|
||||
export type AgentComposerSoulLockResponse = {
|
||||
can_unlock?: boolean
|
||||
locked: boolean
|
||||
reason?: string | null
|
||||
}
|
||||
|
||||
export type AgentComposerImpactBindingResponse = {
|
||||
app_id: string
|
||||
node_id: string
|
||||
workflow_id: string
|
||||
}
|
||||
|
||||
export type WorkflowDraftVariableWithoutValue = {
|
||||
description?: string
|
||||
edited?: boolean
|
||||
@ -1353,6 +1480,10 @@ export type WorkflowOnlineUser = {
|
||||
username: string
|
||||
}
|
||||
|
||||
export type AgentScope = 'roster' | 'workflow_only'
|
||||
|
||||
export type AgentStatus = 'active' | 'archived'
|
||||
|
||||
export type AppVariableConfig = {
|
||||
default?: unknown
|
||||
name: string
|
||||
@ -1433,20 +1564,10 @@ export type AgentSoulToolsConfig = {
|
||||
dify_tools?: Array<AgentSoulDifyToolConfig>
|
||||
}
|
||||
|
||||
export type DeclaredOutputConfig = {
|
||||
array_item?: DeclaredArrayItem
|
||||
check?: DeclaredOutputCheckConfig
|
||||
description?: string | null
|
||||
failure_strategy?: DeclaredOutputFailureStrategy
|
||||
file?: DeclaredOutputFileConfig
|
||||
id?: string | null
|
||||
name: string
|
||||
required?: boolean
|
||||
type: DeclaredOutputType
|
||||
}
|
||||
|
||||
export type WorkflowNodeJobMode = 'let_agent_figure_it_out' | 'tell_agent_what_to_do'
|
||||
|
||||
export type DeclaredOutputType = 'array' | 'boolean' | 'file' | 'number' | 'object' | 'string'
|
||||
|
||||
export type SimpleModelConfig = {
|
||||
model_dict?: JsonValue
|
||||
pre_prompt?: string | null
|
||||
@ -1515,29 +1636,7 @@ export type WorkflowRunForArchivedLogResponse = {
|
||||
triggered_from?: string | null
|
||||
}
|
||||
|
||||
export type AgentKnowledgeQueryMode = 'generated_query' | 'user_query'
|
||||
|
||||
export type AgentSoulModelCredentialRef = {
|
||||
id?: string | null
|
||||
provider?: string | null
|
||||
type: string
|
||||
}
|
||||
|
||||
export type AgentSoulDifyToolConfig = {
|
||||
credential_ref?: AgentSoulDifyToolCredentialRef
|
||||
credential_type?: 'api-key' | 'oauth2' | 'unauthorized'
|
||||
description?: string | null
|
||||
enabled?: boolean
|
||||
name?: string | null
|
||||
plugin_id?: string | null
|
||||
provider?: string | null
|
||||
provider_id?: string | null
|
||||
provider_type?: string
|
||||
runtime_parameters?: {
|
||||
[key: string]: unknown
|
||||
}
|
||||
tool_name: string
|
||||
}
|
||||
export type WorkflowAgentBindingType = 'inline_agent' | 'roster_agent'
|
||||
|
||||
export type DeclaredArrayItem = {
|
||||
description?: string | null
|
||||
@ -1566,7 +1665,29 @@ export type DeclaredOutputFileConfig = {
|
||||
mime_types?: Array<string>
|
||||
}
|
||||
|
||||
export type DeclaredOutputType = 'array' | 'boolean' | 'file' | 'number' | 'object' | 'string'
|
||||
export type AgentKnowledgeQueryMode = 'generated_query' | 'user_query'
|
||||
|
||||
export type AgentSoulModelCredentialRef = {
|
||||
id?: string | null
|
||||
provider?: string | null
|
||||
type: string
|
||||
}
|
||||
|
||||
export type AgentSoulDifyToolConfig = {
|
||||
credential_ref?: AgentSoulDifyToolCredentialRef
|
||||
credential_type?: 'api-key' | 'oauth2' | 'unauthorized'
|
||||
description?: string | null
|
||||
enabled?: boolean
|
||||
name?: string | null
|
||||
plugin_id?: string | null
|
||||
provider?: string | null
|
||||
provider_id?: string | null
|
||||
provider_type?: string
|
||||
runtime_parameters?: {
|
||||
[key: string]: unknown
|
||||
}
|
||||
tool_name: string
|
||||
}
|
||||
|
||||
export type UserActionConfig = {
|
||||
button_style?: ButtonStyle
|
||||
@ -1576,12 +1697,6 @@ export type UserActionConfig = {
|
||||
|
||||
export type FormInputConfig = unknown
|
||||
|
||||
export type AgentSoulDifyToolCredentialRef = {
|
||||
id?: string | null
|
||||
provider?: string | null
|
||||
type?: 'provider' | 'tool'
|
||||
}
|
||||
|
||||
export type OutputErrorStrategy = 'default_value' | 'fail_branch' | 'stop'
|
||||
|
||||
export type DeclaredOutputRetryConfig = {
|
||||
@ -1590,6 +1705,12 @@ export type DeclaredOutputRetryConfig = {
|
||||
retry_interval_ms?: number
|
||||
}
|
||||
|
||||
export type AgentSoulDifyToolCredentialRef = {
|
||||
id?: string | null
|
||||
provider?: string | null
|
||||
type?: 'provider' | 'tool'
|
||||
}
|
||||
|
||||
export type ButtonStyle = 'accent' | 'default' | 'ghost' | 'primary'
|
||||
|
||||
export type ParagraphInputConfig = {
|
||||
@ -2062,9 +2183,7 @@ export type GetAppsByAppIdAgentComposerData = {
|
||||
}
|
||||
|
||||
export type GetAppsByAppIdAgentComposerResponses = {
|
||||
200: {
|
||||
[key: string]: unknown
|
||||
}
|
||||
200: AgentAppComposerResponse
|
||||
}
|
||||
|
||||
export type GetAppsByAppIdAgentComposerResponse
|
||||
@ -2080,9 +2199,7 @@ export type PutAppsByAppIdAgentComposerData = {
|
||||
}
|
||||
|
||||
export type PutAppsByAppIdAgentComposerResponses = {
|
||||
200: {
|
||||
[key: string]: unknown
|
||||
}
|
||||
200: AgentAppComposerResponse
|
||||
}
|
||||
|
||||
export type PutAppsByAppIdAgentComposerResponse
|
||||
@ -2098,9 +2215,7 @@ export type GetAppsByAppIdAgentComposerCandidatesData = {
|
||||
}
|
||||
|
||||
export type GetAppsByAppIdAgentComposerCandidatesResponses = {
|
||||
200: {
|
||||
[key: string]: unknown
|
||||
}
|
||||
200: AgentComposerCandidatesResponse
|
||||
}
|
||||
|
||||
export type GetAppsByAppIdAgentComposerCandidatesResponse
|
||||
@ -2116,9 +2231,7 @@ export type PostAppsByAppIdAgentComposerValidateData = {
|
||||
}
|
||||
|
||||
export type PostAppsByAppIdAgentComposerValidateResponses = {
|
||||
200: {
|
||||
[key: string]: unknown
|
||||
}
|
||||
200: AgentComposerValidateResponse
|
||||
}
|
||||
|
||||
export type PostAppsByAppIdAgentComposerValidateResponse
|
||||
@ -4515,9 +4628,7 @@ export type GetAppsByAppIdWorkflowsDraftNodesByNodeIdAgentComposerData = {
|
||||
}
|
||||
|
||||
export type GetAppsByAppIdWorkflowsDraftNodesByNodeIdAgentComposerResponses = {
|
||||
200: {
|
||||
[key: string]: unknown
|
||||
}
|
||||
200: WorkflowAgentComposerResponse
|
||||
}
|
||||
|
||||
export type GetAppsByAppIdWorkflowsDraftNodesByNodeIdAgentComposerResponse
|
||||
@ -4534,9 +4645,7 @@ export type PutAppsByAppIdWorkflowsDraftNodesByNodeIdAgentComposerData = {
|
||||
}
|
||||
|
||||
export type PutAppsByAppIdWorkflowsDraftNodesByNodeIdAgentComposerResponses = {
|
||||
200: {
|
||||
[key: string]: unknown
|
||||
}
|
||||
200: WorkflowAgentComposerResponse
|
||||
}
|
||||
|
||||
export type PutAppsByAppIdWorkflowsDraftNodesByNodeIdAgentComposerResponse
|
||||
@ -4553,16 +4662,14 @@ export type GetAppsByAppIdWorkflowsDraftNodesByNodeIdAgentComposerCandidatesData
|
||||
}
|
||||
|
||||
export type GetAppsByAppIdWorkflowsDraftNodesByNodeIdAgentComposerCandidatesResponses = {
|
||||
200: {
|
||||
[key: string]: unknown
|
||||
}
|
||||
200: AgentComposerCandidatesResponse
|
||||
}
|
||||
|
||||
export type GetAppsByAppIdWorkflowsDraftNodesByNodeIdAgentComposerCandidatesResponse
|
||||
= GetAppsByAppIdWorkflowsDraftNodesByNodeIdAgentComposerCandidatesResponses[keyof GetAppsByAppIdWorkflowsDraftNodesByNodeIdAgentComposerCandidatesResponses]
|
||||
|
||||
export type PostAppsByAppIdWorkflowsDraftNodesByNodeIdAgentComposerImpactData = {
|
||||
body?: never
|
||||
body: ComposerSavePayload
|
||||
path: {
|
||||
app_id: string
|
||||
node_id: string
|
||||
@ -4572,9 +4679,7 @@ export type PostAppsByAppIdWorkflowsDraftNodesByNodeIdAgentComposerImpactData =
|
||||
}
|
||||
|
||||
export type PostAppsByAppIdWorkflowsDraftNodesByNodeIdAgentComposerImpactResponses = {
|
||||
200: {
|
||||
[key: string]: unknown
|
||||
}
|
||||
200: AgentComposerImpactResponse
|
||||
}
|
||||
|
||||
export type PostAppsByAppIdWorkflowsDraftNodesByNodeIdAgentComposerImpactResponse
|
||||
@ -4591,9 +4696,7 @@ export type PostAppsByAppIdWorkflowsDraftNodesByNodeIdAgentComposerSaveToRosterD
|
||||
}
|
||||
|
||||
export type PostAppsByAppIdWorkflowsDraftNodesByNodeIdAgentComposerSaveToRosterResponses = {
|
||||
200: {
|
||||
[key: string]: unknown
|
||||
}
|
||||
200: WorkflowAgentComposerResponse
|
||||
}
|
||||
|
||||
export type PostAppsByAppIdWorkflowsDraftNodesByNodeIdAgentComposerSaveToRosterResponse
|
||||
@ -4610,9 +4713,7 @@ export type PostAppsByAppIdWorkflowsDraftNodesByNodeIdAgentComposerValidateData
|
||||
}
|
||||
|
||||
export type PostAppsByAppIdWorkflowsDraftNodesByNodeIdAgentComposerValidateResponses = {
|
||||
200: {
|
||||
[key: string]: unknown
|
||||
}
|
||||
200: AgentComposerValidateResponse
|
||||
}
|
||||
|
||||
export type PostAppsByAppIdWorkflowsDraftNodesByNodeIdAgentComposerValidateResponse
|
||||
|
||||
@ -77,6 +77,14 @@ export const zAdvancedChatWorkflowRunPayload = z.object({
|
||||
query: z.string().optional().default(''),
|
||||
})
|
||||
|
||||
/**
|
||||
* AgentComposerValidateResponse
|
||||
*/
|
||||
export const zAgentComposerValidateResponse = z.object({
|
||||
errors: z.array(z.string()).optional(),
|
||||
result: z.string(),
|
||||
})
|
||||
|
||||
/**
|
||||
* AnnotationReplyPayload
|
||||
*/
|
||||
@ -173,7 +181,7 @@ export const zSimpleResultResponse = z.object({
|
||||
export const zCompletionMessagePayload = z.object({
|
||||
files: z.array(z.unknown()).nullish(),
|
||||
inputs: z.record(z.string(), z.unknown()),
|
||||
model_config: z.record(z.string(), z.unknown()),
|
||||
model_config: z.record(z.string(), z.unknown()).optional(),
|
||||
query: z.string().optional().default(''),
|
||||
response_mode: z.enum(['blocking', 'streaming']).optional().default('blocking'),
|
||||
retriever_from: z.string().optional().default('dev'),
|
||||
@ -730,12 +738,16 @@ export const zSite = z.object({
|
||||
})
|
||||
|
||||
/**
|
||||
* ComposerBindingPayload
|
||||
* AgentConfigSnapshotSummaryResponse
|
||||
*/
|
||||
export const zComposerBindingPayload = z.object({
|
||||
export const zAgentConfigSnapshotSummaryResponse = z.object({
|
||||
agent_id: z.string().nullish(),
|
||||
binding_type: z.enum(['inline_agent', 'roster_agent']),
|
||||
current_snapshot_id: z.string().nullish(),
|
||||
created_at: z.string().nullish(),
|
||||
created_by: z.string().nullish(),
|
||||
id: z.string(),
|
||||
summary: z.string().nullish(),
|
||||
version: z.int(),
|
||||
version_note: z.string().nullish(),
|
||||
})
|
||||
|
||||
/**
|
||||
@ -749,6 +761,15 @@ export const zComposerSaveStrategy = z.enum([
|
||||
'save_to_roster',
|
||||
])
|
||||
|
||||
/**
|
||||
* ComposerBindingPayload
|
||||
*/
|
||||
export const zComposerBindingPayload = z.object({
|
||||
agent_id: z.string().nullish(),
|
||||
binding_type: z.enum(['inline_agent', 'roster_agent']),
|
||||
current_snapshot_id: z.string().nullish(),
|
||||
})
|
||||
|
||||
/**
|
||||
* ComposerSoulLockPayload
|
||||
*/
|
||||
@ -762,6 +783,24 @@ export const zComposerSoulLockPayload = z.object({
|
||||
*/
|
||||
export const zComposerVariant = z.enum(['agent_app', 'workflow'])
|
||||
|
||||
/**
|
||||
* AgentComposerSoulCandidatesResponse
|
||||
*/
|
||||
export const zAgentComposerSoulCandidatesResponse = z.object({
|
||||
cli_tools: z.array(z.record(z.string(), z.unknown())).optional(),
|
||||
dify_tools: z.array(z.record(z.string(), z.unknown())).optional(),
|
||||
human_contacts: z.array(z.record(z.string(), z.unknown())).optional(),
|
||||
knowledge_datasets: z.array(z.record(z.string(), z.unknown())).optional(),
|
||||
skills_files: z.array(z.record(z.string(), z.unknown())).optional(),
|
||||
})
|
||||
|
||||
/**
|
||||
* ComposerCandidateCapabilities
|
||||
*/
|
||||
export const zComposerCandidateCapabilities = z.object({
|
||||
human_roster_available: z.boolean().optional().default(false),
|
||||
})
|
||||
|
||||
/**
|
||||
* AnnotationHitHistory
|
||||
*/
|
||||
@ -1235,6 +1274,33 @@ export const zWorkflowPaginationResponse = z.object({
|
||||
page: z.int(),
|
||||
})
|
||||
|
||||
/**
|
||||
* AgentComposerSoulLockResponse
|
||||
*/
|
||||
export const zAgentComposerSoulLockResponse = z.object({
|
||||
can_unlock: z.boolean().optional().default(false),
|
||||
locked: z.boolean(),
|
||||
reason: z.string().nullish(),
|
||||
})
|
||||
|
||||
/**
|
||||
* AgentComposerImpactBindingResponse
|
||||
*/
|
||||
export const zAgentComposerImpactBindingResponse = z.object({
|
||||
app_id: z.string(),
|
||||
node_id: z.string(),
|
||||
workflow_id: z.string(),
|
||||
})
|
||||
|
||||
/**
|
||||
* AgentComposerImpactResponse
|
||||
*/
|
||||
export const zAgentComposerImpactResponse = z.object({
|
||||
bindings: z.array(zAgentComposerImpactBindingResponse).optional(),
|
||||
current_snapshot_id: z.string().nullish(),
|
||||
workflow_node_count: z.int(),
|
||||
})
|
||||
|
||||
export const zWorkflowDraftVariableWithoutValue = z.object({
|
||||
description: z.string().optional(),
|
||||
edited: z.boolean().optional(),
|
||||
@ -1349,6 +1415,7 @@ export const zAppDetailWithSite = z.object({
|
||||
access_mode: z.string().nullish(),
|
||||
api_base_url: z.string().nullish(),
|
||||
app_model_config: zModelConfig.optional(),
|
||||
bound_agent_id: z.string().nullish(),
|
||||
created_at: z.int().nullish(),
|
||||
created_by: z.string().nullish(),
|
||||
deleted_tools: z.array(zDeletedTool).optional(),
|
||||
@ -1475,6 +1542,32 @@ export const zWorkflowOnlineUsersResponse = z.object({
|
||||
data: z.array(zWorkflowOnlineUsersByApp),
|
||||
})
|
||||
|
||||
/**
|
||||
* AgentScope
|
||||
*
|
||||
* Visibility and lifecycle scope of an Agent record.
|
||||
*/
|
||||
export const zAgentScope = z.enum(['roster', 'workflow_only'])
|
||||
|
||||
/**
|
||||
* AgentStatus
|
||||
*
|
||||
* Soft lifecycle state for Agent records.
|
||||
*/
|
||||
export const zAgentStatus = z.enum(['active', 'archived'])
|
||||
|
||||
/**
|
||||
* AgentComposerAgentResponse
|
||||
*/
|
||||
export const zAgentComposerAgentResponse = z.object({
|
||||
active_config_snapshot_id: z.string().nullish(),
|
||||
description: z.string(),
|
||||
id: z.string(),
|
||||
name: z.string(),
|
||||
scope: zAgentScope,
|
||||
status: zAgentStatus,
|
||||
})
|
||||
|
||||
/**
|
||||
* AppVariableConfig
|
||||
*/
|
||||
@ -1538,6 +1631,37 @@ export const zAgentSoulSkillsFilesConfig = z.object({
|
||||
*/
|
||||
export const zWorkflowNodeJobMode = z.enum(['let_agent_figure_it_out', 'tell_agent_what_to_do'])
|
||||
|
||||
/**
|
||||
* DeclaredOutputType
|
||||
*/
|
||||
export const zDeclaredOutputType = z.enum([
|
||||
'array',
|
||||
'boolean',
|
||||
'file',
|
||||
'number',
|
||||
'object',
|
||||
'string',
|
||||
])
|
||||
|
||||
/**
|
||||
* AgentComposerNodeJobCandidatesResponse
|
||||
*/
|
||||
export const zAgentComposerNodeJobCandidatesResponse = z.object({
|
||||
declare_output_types: z.array(zDeclaredOutputType).optional(),
|
||||
human_contacts: z.array(z.record(z.string(), z.unknown())).optional(),
|
||||
previous_node_outputs: z.array(z.record(z.string(), z.unknown())).optional(),
|
||||
})
|
||||
|
||||
/**
|
||||
* AgentComposerCandidatesResponse
|
||||
*/
|
||||
export const zAgentComposerCandidatesResponse = z.object({
|
||||
allowed_node_job_candidates: zAgentComposerNodeJobCandidatesResponse.optional(),
|
||||
allowed_soul_candidates: zAgentComposerSoulCandidatesResponse.optional(),
|
||||
capabilities: zComposerCandidateCapabilities.optional(),
|
||||
variant: zComposerVariant,
|
||||
})
|
||||
|
||||
/**
|
||||
* SimpleModelConfig
|
||||
*/
|
||||
@ -1725,6 +1849,63 @@ export const zWorkflowArchivedLogPaginationResponse = z.object({
|
||||
total: z.int(),
|
||||
})
|
||||
|
||||
/**
|
||||
* WorkflowAgentBindingType
|
||||
*
|
||||
* How a workflow node is bound to an Agent.
|
||||
*/
|
||||
export const zWorkflowAgentBindingType = z.enum(['inline_agent', 'roster_agent'])
|
||||
|
||||
/**
|
||||
* AgentComposerBindingResponse
|
||||
*/
|
||||
export const zAgentComposerBindingResponse = z.object({
|
||||
agent_id: z.string().nullish(),
|
||||
binding_type: zWorkflowAgentBindingType,
|
||||
current_snapshot_id: z.string().nullish(),
|
||||
id: z.string(),
|
||||
node_id: z.string(),
|
||||
workflow_id: z.string(),
|
||||
})
|
||||
|
||||
/**
|
||||
* DeclaredArrayItem
|
||||
*
|
||||
* 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.
|
||||
*/
|
||||
export const zDeclaredArrayItem = z.object({
|
||||
description: z.string().nullish(),
|
||||
type: zDeclaredOutputType,
|
||||
})
|
||||
|
||||
/**
|
||||
* DeclaredOutputCheckConfig
|
||||
*
|
||||
* 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.
|
||||
*/
|
||||
export const zDeclaredOutputCheckConfig = z.object({
|
||||
benchmark_file_ref: z.record(z.string(), z.unknown()).nullish(),
|
||||
enabled: z.boolean().optional().default(false),
|
||||
model_ref: z.record(z.string(), z.unknown()).nullish(),
|
||||
prompt: z.string().nullish(),
|
||||
})
|
||||
|
||||
/**
|
||||
* DeclaredOutputFileConfig
|
||||
*
|
||||
* File-type output metadata. Both lists empty means "any file accepted".
|
||||
*/
|
||||
export const zDeclaredOutputFileConfig = z.object({
|
||||
extensions: z.array(z.string()).optional(),
|
||||
mime_types: z.array(z.string()).optional(),
|
||||
})
|
||||
|
||||
/**
|
||||
* AgentKnowledgeQueryMode
|
||||
*/
|
||||
@ -1763,124 +1944,8 @@ export const zAgentSoulModelConfig = z.object({
|
||||
plugin_id: z.string().min(1).max(255),
|
||||
})
|
||||
|
||||
/**
|
||||
* DeclaredOutputCheckConfig
|
||||
*
|
||||
* 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.
|
||||
*/
|
||||
export const zDeclaredOutputCheckConfig = z.object({
|
||||
benchmark_file_ref: z.record(z.string(), z.unknown()).nullish(),
|
||||
enabled: z.boolean().optional().default(false),
|
||||
model_ref: z.record(z.string(), z.unknown()).nullish(),
|
||||
prompt: z.string().nullish(),
|
||||
})
|
||||
|
||||
/**
|
||||
* DeclaredOutputFileConfig
|
||||
*
|
||||
* File-type output metadata. Both lists empty means "any file accepted".
|
||||
*/
|
||||
export const zDeclaredOutputFileConfig = z.object({
|
||||
extensions: z.array(z.string()).optional(),
|
||||
mime_types: z.array(z.string()).optional(),
|
||||
})
|
||||
|
||||
/**
|
||||
* DeclaredOutputType
|
||||
*/
|
||||
export const zDeclaredOutputType = z.enum([
|
||||
'array',
|
||||
'boolean',
|
||||
'file',
|
||||
'number',
|
||||
'object',
|
||||
'string',
|
||||
])
|
||||
|
||||
/**
|
||||
* DeclaredArrayItem
|
||||
*
|
||||
* 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.
|
||||
*/
|
||||
export const zDeclaredArrayItem = z.object({
|
||||
description: z.string().nullish(),
|
||||
type: zDeclaredOutputType,
|
||||
})
|
||||
|
||||
export const zFormInputConfig = z.unknown()
|
||||
|
||||
/**
|
||||
* AgentSoulDifyToolCredentialRef
|
||||
*
|
||||
* 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.
|
||||
*/
|
||||
export const zAgentSoulDifyToolCredentialRef = z.object({
|
||||
id: z.string().max(255).nullish(),
|
||||
provider: z.string().max(255).nullish(),
|
||||
type: z.enum(['provider', 'tool']).optional().default('tool'),
|
||||
})
|
||||
|
||||
/**
|
||||
* AgentSoulDifyToolConfig
|
||||
*
|
||||
* 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.
|
||||
*/
|
||||
export const zAgentSoulDifyToolConfig = z.object({
|
||||
credential_ref: zAgentSoulDifyToolCredentialRef.optional(),
|
||||
credential_type: z.enum(['api-key', 'oauth2', 'unauthorized']).optional().default('api-key'),
|
||||
description: z.string().nullish(),
|
||||
enabled: z.boolean().optional().default(true),
|
||||
name: z.string().max(255).nullish(),
|
||||
plugin_id: z.string().max(255).nullish(),
|
||||
provider: z.string().max(255).nullish(),
|
||||
provider_id: z.string().max(255).nullish(),
|
||||
provider_type: z.string().optional().default('plugin'),
|
||||
runtime_parameters: z.record(z.string(), z.unknown()).optional(),
|
||||
tool_name: z.string().min(1).max(255),
|
||||
})
|
||||
|
||||
/**
|
||||
* AgentSoulToolsConfig
|
||||
*/
|
||||
export const zAgentSoulToolsConfig = z.object({
|
||||
cli_tools: z.array(z.record(z.string(), z.unknown())).optional(),
|
||||
dify_tools: z.array(zAgentSoulDifyToolConfig).optional(),
|
||||
})
|
||||
|
||||
/**
|
||||
* AgentSoulConfig
|
||||
*/
|
||||
export const zAgentSoulConfig = z.object({
|
||||
app_features: z.record(z.string(), z.unknown()).optional(),
|
||||
app_variables: z.array(zAppVariableConfig).optional(),
|
||||
env: zAgentSoulEnvConfig.optional(),
|
||||
human: zAgentSoulHumanConfig.optional(),
|
||||
knowledge: zAgentSoulKnowledgeConfig.optional(),
|
||||
memory: zAgentSoulMemoryConfig.optional(),
|
||||
misc_legacy: z.record(z.string(), z.unknown()).optional(),
|
||||
model: zAgentSoulModelConfig.optional(),
|
||||
prompt: zAgentSoulPromptConfig.optional(),
|
||||
sandbox: zAgentSoulSandboxConfig.optional(),
|
||||
schema_version: z.int().optional().default(1),
|
||||
skills_files: zAgentSoulSkillsFilesConfig.optional(),
|
||||
tools: zAgentSoulToolsConfig.optional(),
|
||||
})
|
||||
|
||||
/**
|
||||
* OutputErrorStrategy
|
||||
*
|
||||
@ -1951,6 +2016,83 @@ export const zWorkflowNodeJobConfig = z.object({
|
||||
workflow_prompt: z.string().optional().default(''),
|
||||
})
|
||||
|
||||
/**
|
||||
* AgentSoulDifyToolCredentialRef
|
||||
*
|
||||
* 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.
|
||||
*/
|
||||
export const zAgentSoulDifyToolCredentialRef = z.object({
|
||||
id: z.string().max(255).nullish(),
|
||||
provider: z.string().max(255).nullish(),
|
||||
type: z.enum(['provider', 'tool']).optional().default('tool'),
|
||||
})
|
||||
|
||||
/**
|
||||
* AgentSoulDifyToolConfig
|
||||
*
|
||||
* 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.
|
||||
*/
|
||||
export const zAgentSoulDifyToolConfig = z.object({
|
||||
credential_ref: zAgentSoulDifyToolCredentialRef.optional(),
|
||||
credential_type: z.enum(['api-key', 'oauth2', 'unauthorized']).optional().default('api-key'),
|
||||
description: z.string().nullish(),
|
||||
enabled: z.boolean().optional().default(true),
|
||||
name: z.string().max(255).nullish(),
|
||||
plugin_id: z.string().max(255).nullish(),
|
||||
provider: z.string().max(255).nullish(),
|
||||
provider_id: z.string().max(255).nullish(),
|
||||
provider_type: z.string().optional().default('plugin'),
|
||||
runtime_parameters: z.record(z.string(), z.unknown()).optional(),
|
||||
tool_name: z.string().min(1).max(255),
|
||||
})
|
||||
|
||||
/**
|
||||
* AgentSoulToolsConfig
|
||||
*/
|
||||
export const zAgentSoulToolsConfig = z.object({
|
||||
cli_tools: z.array(z.record(z.string(), z.unknown())).optional(),
|
||||
dify_tools: z.array(zAgentSoulDifyToolConfig).optional(),
|
||||
})
|
||||
|
||||
/**
|
||||
* AgentSoulConfig
|
||||
*/
|
||||
export const zAgentSoulConfig = z.object({
|
||||
app_features: z.record(z.string(), z.unknown()).optional(),
|
||||
app_variables: z.array(zAppVariableConfig).optional(),
|
||||
env: zAgentSoulEnvConfig.optional(),
|
||||
human: zAgentSoulHumanConfig.optional(),
|
||||
knowledge: zAgentSoulKnowledgeConfig.optional(),
|
||||
memory: zAgentSoulMemoryConfig.optional(),
|
||||
misc_legacy: z.record(z.string(), z.unknown()).optional(),
|
||||
model: zAgentSoulModelConfig.optional(),
|
||||
prompt: zAgentSoulPromptConfig.optional(),
|
||||
sandbox: zAgentSoulSandboxConfig.optional(),
|
||||
schema_version: z.int().optional().default(1),
|
||||
skills_files: zAgentSoulSkillsFilesConfig.optional(),
|
||||
tools: zAgentSoulToolsConfig.optional(),
|
||||
})
|
||||
|
||||
/**
|
||||
* AgentAppComposerResponse
|
||||
*/
|
||||
export const zAgentAppComposerResponse = z.object({
|
||||
active_config_snapshot: zAgentConfigSnapshotSummaryResponse,
|
||||
agent: zAgentComposerAgentResponse,
|
||||
agent_soul: zAgentSoulConfig,
|
||||
save_options: z.array(zComposerSaveStrategy),
|
||||
variant: z.string(),
|
||||
})
|
||||
|
||||
/**
|
||||
* ComposerSavePayload
|
||||
*/
|
||||
@ -1967,6 +2109,25 @@ export const zComposerSavePayload = z.object({
|
||||
version_note: z.string().nullish(),
|
||||
})
|
||||
|
||||
/**
|
||||
* WorkflowAgentComposerResponse
|
||||
*/
|
||||
export const zWorkflowAgentComposerResponse = z.object({
|
||||
active_config_snapshot: zAgentConfigSnapshotSummaryResponse.optional(),
|
||||
agent: zAgentComposerAgentResponse.optional(),
|
||||
agent_soul: zAgentSoulConfig,
|
||||
app_id: z.string().nullish(),
|
||||
binding: zAgentComposerBindingResponse.optional(),
|
||||
effective_declared_outputs: z.array(zDeclaredOutputConfig).optional(),
|
||||
impact_summary: zAgentComposerImpactResponse.optional(),
|
||||
node_id: z.string().nullish(),
|
||||
node_job: zWorkflowNodeJobConfig,
|
||||
save_options: z.array(zComposerSaveStrategy),
|
||||
soul_lock: zAgentComposerSoulLockResponse,
|
||||
variant: z.string(),
|
||||
workflow_id: z.string().nullish(),
|
||||
})
|
||||
|
||||
/**
|
||||
* ButtonStyle
|
||||
*
|
||||
@ -2412,9 +2573,9 @@ export const zGetAppsByAppIdAgentComposerPath = z.object({
|
||||
})
|
||||
|
||||
/**
|
||||
* Success
|
||||
* Agent app composer state
|
||||
*/
|
||||
export const zGetAppsByAppIdAgentComposerResponse = z.record(z.string(), z.unknown())
|
||||
export const zGetAppsByAppIdAgentComposerResponse = zAgentAppComposerResponse
|
||||
|
||||
export const zPutAppsByAppIdAgentComposerBody = zComposerSavePayload
|
||||
|
||||
@ -2423,18 +2584,18 @@ export const zPutAppsByAppIdAgentComposerPath = z.object({
|
||||
})
|
||||
|
||||
/**
|
||||
* Success
|
||||
* Agent app composer saved
|
||||
*/
|
||||
export const zPutAppsByAppIdAgentComposerResponse = z.record(z.string(), z.unknown())
|
||||
export const zPutAppsByAppIdAgentComposerResponse = zAgentAppComposerResponse
|
||||
|
||||
export const zGetAppsByAppIdAgentComposerCandidatesPath = z.object({
|
||||
app_id: z.string(),
|
||||
})
|
||||
|
||||
/**
|
||||
* Success
|
||||
* Agent app composer candidates
|
||||
*/
|
||||
export const zGetAppsByAppIdAgentComposerCandidatesResponse = z.record(z.string(), z.unknown())
|
||||
export const zGetAppsByAppIdAgentComposerCandidatesResponse = zAgentComposerCandidatesResponse
|
||||
|
||||
export const zPostAppsByAppIdAgentComposerValidateBody = zComposerSavePayload
|
||||
|
||||
@ -2443,9 +2604,9 @@ export const zPostAppsByAppIdAgentComposerValidatePath = z.object({
|
||||
})
|
||||
|
||||
/**
|
||||
* Success
|
||||
* Agent app composer validation result
|
||||
*/
|
||||
export const zPostAppsByAppIdAgentComposerValidateResponse = z.record(z.string(), z.unknown())
|
||||
export const zPostAppsByAppIdAgentComposerValidateResponse = zAgentComposerValidateResponse
|
||||
|
||||
export const zGetAppsByAppIdAgentLogsPath = z.object({
|
||||
app_id: z.string(),
|
||||
@ -3723,12 +3884,10 @@ export const zGetAppsByAppIdWorkflowsDraftNodesByNodeIdAgentComposerPath = z.obj
|
||||
})
|
||||
|
||||
/**
|
||||
* Success
|
||||
* Workflow agent composer state
|
||||
*/
|
||||
export const zGetAppsByAppIdWorkflowsDraftNodesByNodeIdAgentComposerResponse = z.record(
|
||||
z.string(),
|
||||
z.unknown(),
|
||||
)
|
||||
export const zGetAppsByAppIdWorkflowsDraftNodesByNodeIdAgentComposerResponse
|
||||
= zWorkflowAgentComposerResponse
|
||||
|
||||
export const zPutAppsByAppIdWorkflowsDraftNodesByNodeIdAgentComposerBody = zComposerSavePayload
|
||||
|
||||
@ -3738,12 +3897,10 @@ export const zPutAppsByAppIdWorkflowsDraftNodesByNodeIdAgentComposerPath = z.obj
|
||||
})
|
||||
|
||||
/**
|
||||
* Success
|
||||
* Workflow agent composer saved
|
||||
*/
|
||||
export const zPutAppsByAppIdWorkflowsDraftNodesByNodeIdAgentComposerResponse = z.record(
|
||||
z.string(),
|
||||
z.unknown(),
|
||||
)
|
||||
export const zPutAppsByAppIdWorkflowsDraftNodesByNodeIdAgentComposerResponse
|
||||
= zWorkflowAgentComposerResponse
|
||||
|
||||
export const zGetAppsByAppIdWorkflowsDraftNodesByNodeIdAgentComposerCandidatesPath = z.object({
|
||||
app_id: z.string(),
|
||||
@ -3751,12 +3908,13 @@ export const zGetAppsByAppIdWorkflowsDraftNodesByNodeIdAgentComposerCandidatesPa
|
||||
})
|
||||
|
||||
/**
|
||||
* Success
|
||||
* Workflow agent composer candidates
|
||||
*/
|
||||
export const zGetAppsByAppIdWorkflowsDraftNodesByNodeIdAgentComposerCandidatesResponse = z.record(
|
||||
z.string(),
|
||||
z.unknown(),
|
||||
)
|
||||
export const zGetAppsByAppIdWorkflowsDraftNodesByNodeIdAgentComposerCandidatesResponse
|
||||
= zAgentComposerCandidatesResponse
|
||||
|
||||
export const zPostAppsByAppIdWorkflowsDraftNodesByNodeIdAgentComposerImpactBody
|
||||
= zComposerSavePayload
|
||||
|
||||
export const zPostAppsByAppIdWorkflowsDraftNodesByNodeIdAgentComposerImpactPath = z.object({
|
||||
app_id: z.string(),
|
||||
@ -3764,12 +3922,10 @@ export const zPostAppsByAppIdWorkflowsDraftNodesByNodeIdAgentComposerImpactPath
|
||||
})
|
||||
|
||||
/**
|
||||
* Success
|
||||
* Workflow agent composer impact
|
||||
*/
|
||||
export const zPostAppsByAppIdWorkflowsDraftNodesByNodeIdAgentComposerImpactResponse = z.record(
|
||||
z.string(),
|
||||
z.unknown(),
|
||||
)
|
||||
export const zPostAppsByAppIdWorkflowsDraftNodesByNodeIdAgentComposerImpactResponse
|
||||
= zAgentComposerImpactResponse
|
||||
|
||||
export const zPostAppsByAppIdWorkflowsDraftNodesByNodeIdAgentComposerSaveToRosterBody
|
||||
= zComposerSavePayload
|
||||
@ -3780,10 +3936,10 @@ export const zPostAppsByAppIdWorkflowsDraftNodesByNodeIdAgentComposerSaveToRoste
|
||||
})
|
||||
|
||||
/**
|
||||
* Success
|
||||
* Workflow agent composer saved to roster
|
||||
*/
|
||||
export const zPostAppsByAppIdWorkflowsDraftNodesByNodeIdAgentComposerSaveToRosterResponse
|
||||
= z.record(z.string(), z.unknown())
|
||||
= zWorkflowAgentComposerResponse
|
||||
|
||||
export const zPostAppsByAppIdWorkflowsDraftNodesByNodeIdAgentComposerValidateBody
|
||||
= zComposerSavePayload
|
||||
@ -3794,12 +3950,10 @@ export const zPostAppsByAppIdWorkflowsDraftNodesByNodeIdAgentComposerValidatePat
|
||||
})
|
||||
|
||||
/**
|
||||
* Success
|
||||
* Workflow agent composer validation result
|
||||
*/
|
||||
export const zPostAppsByAppIdWorkflowsDraftNodesByNodeIdAgentComposerValidateResponse = z.record(
|
||||
z.string(),
|
||||
z.unknown(),
|
||||
)
|
||||
export const zPostAppsByAppIdWorkflowsDraftNodesByNodeIdAgentComposerValidateResponse
|
||||
= zAgentComposerValidateResponse
|
||||
|
||||
export const zGetAppsByAppIdWorkflowsDraftNodesByNodeIdLastRunPath = z.object({
|
||||
app_id: z.string(),
|
||||
|
||||
@ -23,7 +23,7 @@ export type ChatMessagePayload = {
|
||||
inputs: {
|
||||
[key: string]: unknown
|
||||
}
|
||||
model_config: {
|
||||
model_config?: {
|
||||
[key: string]: unknown
|
||||
}
|
||||
parent_message_id?: string | null
|
||||
|
||||
@ -24,7 +24,7 @@ export const zChatMessagePayload = z.object({
|
||||
conversation_id: z.string().nullish(),
|
||||
files: z.array(z.unknown()).nullish(),
|
||||
inputs: z.record(z.string(), z.unknown()),
|
||||
model_config: z.record(z.string(), z.unknown()),
|
||||
model_config: z.record(z.string(), z.unknown()).optional(),
|
||||
parent_message_id: z.string().nullish(),
|
||||
query: z.string(),
|
||||
response_mode: z.enum(['blocking', 'streaming']).optional().default('blocking'),
|
||||
|
||||
@ -86,6 +86,7 @@ export type AppListRow = {
|
||||
|
||||
export type AppMode
|
||||
= | 'advanced-chat'
|
||||
| 'agent'
|
||||
| 'agent-chat'
|
||||
| 'channel'
|
||||
| 'chat'
|
||||
|
||||
@ -28,6 +28,7 @@ export const zAppDescribeQuery = z.object({
|
||||
*/
|
||||
export const zAppMode = z.enum([
|
||||
'advanced-chat',
|
||||
'agent',
|
||||
'agent-chat',
|
||||
'channel',
|
||||
'chat',
|
||||
|
||||
@ -6,10 +6,9 @@ import DevicePage from '../page'
|
||||
const mockPush = vi.fn()
|
||||
const mockReplace = vi.fn()
|
||||
const mockDeviceLookup = vi.fn()
|
||||
let mockSearchParams: Record<string, string | null> = {}
|
||||
|
||||
vi.mock('@/next/navigation', () => ({
|
||||
useSearchParams: () => ({ get: (key: string) => mockSearchParams[key] ?? null }),
|
||||
useSearchParams: () => ({ get: () => null }),
|
||||
useRouter: () => ({ push: mockPush, replace: mockReplace }),
|
||||
usePathname: () => '/device',
|
||||
}))
|
||||
@ -54,12 +53,6 @@ let MockDeviceFlowError: MockDeviceFlowErrorCtor
|
||||
|
||||
beforeEach(async () => {
|
||||
vi.clearAllMocks()
|
||||
mockSearchParams = {}
|
||||
// router.replace(pathname) in the real app drops the query string; mirror
|
||||
// that so useSearchParams reflects the cleared URL on the next render.
|
||||
mockReplace.mockImplementation(() => {
|
||||
mockSearchParams = {}
|
||||
})
|
||||
mockUseQuery.mockReturnValue({ data: undefined, isError: false } as ReturnType<typeof useQuery>)
|
||||
const mod = await import('@/service/device-flow') as { DeviceFlowError: MockDeviceFlowErrorCtor }
|
||||
MockDeviceFlowError = mod.DeviceFlowError
|
||||
@ -117,41 +110,3 @@ describe('error_lookup_failed terminal state', () => {
|
||||
expect(screen.queryByText('Could not verify the code')).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('sso_error inline banner on the code-entry page', () => {
|
||||
const SSO_BANNER_COPY = /identity is linked to a Dify account/i
|
||||
|
||||
it('shows the error banner with friendly copy when sso_error is present', async () => {
|
||||
mockSearchParams = { sso_error: 'email_belongs_to_dify_account' }
|
||||
render(<DevicePage />)
|
||||
expect(await screen.findByText(SSO_BANNER_COPY)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('keeps the code-entry screen visible (error on main page, not a separate view)', async () => {
|
||||
mockSearchParams = { sso_error: 'email_belongs_to_dify_account' }
|
||||
render(<DevicePage />)
|
||||
await screen.findByText(SSO_BANNER_COPY)
|
||||
expect(screen.getByRole('textbox')).toBeInTheDocument()
|
||||
expect(screen.getByRole('button', { name: /Continue/i })).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('does not surface the raw backend error code', async () => {
|
||||
mockSearchParams = { sso_error: 'email_belongs_to_dify_account' }
|
||||
render(<DevicePage />)
|
||||
await screen.findByText(SSO_BANNER_COPY)
|
||||
expect(screen.queryByText('email_belongs_to_dify_account')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('does not scrub the param on mount (regression: error was wiped by router.replace)', async () => {
|
||||
mockSearchParams = { sso_error: 'email_belongs_to_dify_account' }
|
||||
render(<DevicePage />)
|
||||
await screen.findByText(SSO_BANNER_COPY)
|
||||
expect(mockReplace).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('shows no banner when sso_error is absent', () => {
|
||||
render(<DevicePage />)
|
||||
expect(screen.getByRole('textbox')).toBeInTheDocument()
|
||||
expect(screen.queryByText(SSO_BANNER_COPY)).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
@ -14,7 +14,7 @@ import AuthorizeAccount from './components/authorize-account'
|
||||
import AuthorizeSSO from './components/authorize-sso'
|
||||
import Chooser from './components/chooser'
|
||||
import CodeInput from './components/code-input'
|
||||
import { classifyLookupError, ssoErrorCopy } from './utils/error-copy'
|
||||
import { classifyLookupError } from './utils/error-copy'
|
||||
import { isValidUserCode } from './utils/user-code'
|
||||
|
||||
type View
|
||||
@ -33,7 +33,6 @@ export default function DevicePage() {
|
||||
const pathname = usePathname()
|
||||
const urlUserCode = (searchParams.get('user_code') || '').trim().toUpperCase()
|
||||
const ssoVerified = searchParams.get('sso_verified') === '1'
|
||||
const ssoError = searchParams.get('sso_error') || ''
|
||||
|
||||
const [typed, setTyped] = useState('')
|
||||
const [view, setView] = useState<View>({ kind: 'code_entry' })
|
||||
@ -126,12 +125,6 @@ export default function DevicePage() {
|
||||
<>
|
||||
{view.kind === 'code_entry' && (
|
||||
<div className="flex flex-col gap-5">
|
||||
{ssoError && (
|
||||
<div className="flex items-start gap-2 rounded-lg bg-state-destructive-hover p-3">
|
||||
<span className="mt-0.5 i-ri-close-circle-line h-4 w-4 shrink-0 text-util-colors-red-red-600" />
|
||||
<p className="text-sm text-text-destructive">{ssoErrorCopy(ssoError)}</p>
|
||||
</div>
|
||||
)}
|
||||
<div>
|
||||
<h1 className="text-2xl font-semibold text-text-primary">Authorize Dify CLI</h1>
|
||||
<p className="mt-2 text-sm text-text-secondary">
|
||||
|
||||
@ -30,18 +30,6 @@ export function approveErrorCopy(err: unknown): string {
|
||||
return DEFAULT_MESSAGE
|
||||
}
|
||||
|
||||
// SSO-branch failures arrive as a `sso_error` query param set by the backend
|
||||
// (oauth_device_sso sso-complete) when it redirects back to /device.
|
||||
const SSO_ERROR_COPY: Record<string, string> = {
|
||||
email_belongs_to_dify_account: 'This identity is linked to a Dify account. Use “Sign in with Dify account” instead.',
|
||||
}
|
||||
|
||||
const DEFAULT_SSO_ERROR_MESSAGE = 'Single sign-on could not be completed. Try again.'
|
||||
|
||||
export function ssoErrorCopy(code: string): string {
|
||||
return SSO_ERROR_COPY[code] ?? DEFAULT_SSO_ERROR_MESSAGE
|
||||
}
|
||||
|
||||
export type LookupOutcome = 'expired' | 'rate_limited' | 'failed'
|
||||
|
||||
export function classifyLookupError(err: unknown): LookupOutcome {
|
||||
|
||||
Reference in New Issue
Block a user