mirror of
https://github.com/langgenius/dify.git
synced 2026-09-24 23:22:26 +08:00
fix(agent): support agent-id chat and inline draft bindings (#37483)
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
This commit is contained in:
co-authored by
autofix-ci[bot]
parent
30506f7221
commit
b4e3a9095b
@@ -1,5 +1,6 @@
|
||||
import logging
|
||||
from typing import Any, Literal
|
||||
from uuid import UUID
|
||||
|
||||
from flask import request
|
||||
from flask_restx import Resource
|
||||
@@ -10,6 +11,7 @@ import services
|
||||
from controllers.common.fields import GeneratedAppResponse, SimpleResultResponse
|
||||
from controllers.common.schema import register_response_schema_models, register_schema_models
|
||||
from controllers.console import console_ns
|
||||
from controllers.console.agent.app_helpers import resolve_agent_app_model
|
||||
from controllers.console.app.error import (
|
||||
AppUnavailableError,
|
||||
CompletionRequestError,
|
||||
@@ -23,6 +25,7 @@ from controllers.console.wraps import (
|
||||
account_initialization_required,
|
||||
edit_permission_required,
|
||||
setup_required,
|
||||
with_current_tenant_id,
|
||||
with_current_user,
|
||||
with_current_user_id,
|
||||
)
|
||||
@@ -186,51 +189,27 @@ class ChatMessageApi(Resource):
|
||||
@edit_permission_required
|
||||
@with_current_user
|
||||
def post(self, current_user: Account, app_model: App):
|
||||
raw_payload = console_ns.payload or {}
|
||||
args_model = ChatMessagePayload.model_validate(raw_payload)
|
||||
args = args_model.model_dump(exclude_none=True, by_alias=True)
|
||||
return _create_chat_message(current_user=current_user, app_model=app_model)
|
||||
|
||||
streaming = _resolve_debugger_chat_streaming(
|
||||
app_mode=AppMode.value_of(app_model.mode),
|
||||
response_mode=args_model.response_mode,
|
||||
response_mode_provided=isinstance(raw_payload, dict) and "response_mode" in raw_payload,
|
||||
)
|
||||
if AppMode.value_of(app_model.mode) == AppMode.AGENT:
|
||||
args["response_mode"] = "streaming"
|
||||
args["auto_generate_name"] = False
|
||||
|
||||
external_trace_id = get_external_trace_id(request)
|
||||
if external_trace_id:
|
||||
args["external_trace_id"] = external_trace_id
|
||||
|
||||
try:
|
||||
response = AppGenerateService.generate(
|
||||
app_model=app_model, user=current_user, args=args, invoke_from=InvokeFrom.DEBUGGER, streaming=streaming
|
||||
)
|
||||
|
||||
return helper.compact_generate_response(response)
|
||||
except services.errors.conversation.ConversationNotExistsError:
|
||||
raise NotFound("Conversation Not Exists.")
|
||||
except services.errors.conversation.ConversationCompletedError:
|
||||
raise ConversationCompletedError()
|
||||
except services.errors.app_model_config.AppModelConfigBrokenError:
|
||||
logger.exception("App model config broken.")
|
||||
raise AppUnavailableError()
|
||||
except ProviderTokenNotInitError as ex:
|
||||
raise ProviderNotInitializeError(ex.description)
|
||||
except QuotaExceededError:
|
||||
raise ProviderQuotaExceededError()
|
||||
except ModelCurrentlyNotSupportError:
|
||||
raise ProviderModelCurrentlyNotSupportError()
|
||||
except InvokeRateLimitError as ex:
|
||||
raise InvokeRateLimitHttpError(ex.description)
|
||||
except InvokeError as e:
|
||||
raise CompletionRequestError(e.description)
|
||||
except ValueError as e:
|
||||
raise e
|
||||
except Exception as e:
|
||||
logger.exception("internal server error.")
|
||||
raise InternalServerError()
|
||||
@console_ns.route("/agent/<uuid:agent_id>/chat-messages")
|
||||
class AgentChatMessageApi(Resource):
|
||||
@console_ns.doc("create_agent_chat_message")
|
||||
@console_ns.doc(description="Generate an Agent App chat message for debugging")
|
||||
@console_ns.doc(params={"agent_id": "Agent ID"})
|
||||
@console_ns.expect(console_ns.models[ChatMessagePayload.__name__])
|
||||
@console_ns.response(200, "Chat message generated successfully", console_ns.models[GeneratedAppResponse.__name__])
|
||||
@console_ns.response(400, "Invalid request parameters")
|
||||
@console_ns.response(404, "Agent or conversation not found")
|
||||
@setup_required
|
||||
@login_required
|
||||
@account_initialization_required
|
||||
@edit_permission_required
|
||||
@with_current_user
|
||||
@with_current_tenant_id
|
||||
def post(self, current_tenant_id: str, current_user: Account, agent_id: UUID):
|
||||
app_model = resolve_agent_app_model(tenant_id=current_tenant_id, agent_id=agent_id)
|
||||
return _create_chat_message(current_user=current_user, app_model=app_model)
|
||||
|
||||
|
||||
@console_ns.route("/apps/<uuid:app_id>/chat-messages/<string:task_id>/stop")
|
||||
@@ -245,12 +224,79 @@ class ChatMessageStopApi(Resource):
|
||||
@get_app_model(mode=[AppMode.CHAT, AppMode.AGENT_CHAT, AppMode.ADVANCED_CHAT, AppMode.AGENT])
|
||||
@with_current_user_id
|
||||
def post(self, current_user_id: str, app_model: App, task_id: str):
|
||||
return _stop_chat_message(current_user_id=current_user_id, app_model=app_model, task_id=task_id)
|
||||
|
||||
AppTaskService.stop_task(
|
||||
task_id=task_id,
|
||||
invoke_from=InvokeFrom.DEBUGGER,
|
||||
user_id=current_user_id,
|
||||
app_mode=AppMode.value_of(app_model.mode),
|
||||
|
||||
@console_ns.route("/agent/<uuid:agent_id>/chat-messages/<string:task_id>/stop")
|
||||
class AgentChatMessageStopApi(Resource):
|
||||
@console_ns.doc("stop_agent_chat_message")
|
||||
@console_ns.doc(description="Stop a running Agent App chat message generation")
|
||||
@console_ns.doc(params={"agent_id": "Agent ID", "task_id": "Task ID to stop"})
|
||||
@console_ns.response(200, "Task stopped successfully", console_ns.models[SimpleResultResponse.__name__])
|
||||
@setup_required
|
||||
@login_required
|
||||
@account_initialization_required
|
||||
@with_current_user_id
|
||||
@with_current_tenant_id
|
||||
def post(self, current_tenant_id: str, current_user_id: str, agent_id: UUID, task_id: str):
|
||||
app_model = resolve_agent_app_model(tenant_id=current_tenant_id, agent_id=agent_id)
|
||||
return _stop_chat_message(current_user_id=current_user_id, app_model=app_model, task_id=task_id)
|
||||
|
||||
|
||||
def _create_chat_message(*, current_user: Account, app_model: App):
|
||||
raw_payload = console_ns.payload or {}
|
||||
args_model = ChatMessagePayload.model_validate(raw_payload)
|
||||
args = args_model.model_dump(exclude_none=True, by_alias=True)
|
||||
|
||||
streaming = _resolve_debugger_chat_streaming(
|
||||
app_mode=AppMode.value_of(app_model.mode),
|
||||
response_mode=args_model.response_mode,
|
||||
response_mode_provided=isinstance(raw_payload, dict) and "response_mode" in raw_payload,
|
||||
)
|
||||
if AppMode.value_of(app_model.mode) == AppMode.AGENT:
|
||||
args["response_mode"] = "streaming"
|
||||
args["auto_generate_name"] = False
|
||||
|
||||
external_trace_id = get_external_trace_id(request)
|
||||
if external_trace_id:
|
||||
args["external_trace_id"] = external_trace_id
|
||||
|
||||
try:
|
||||
response = AppGenerateService.generate(
|
||||
app_model=app_model, user=current_user, args=args, invoke_from=InvokeFrom.DEBUGGER, streaming=streaming
|
||||
)
|
||||
|
||||
return {"result": "success"}, 200
|
||||
return helper.compact_generate_response(response)
|
||||
except services.errors.conversation.ConversationNotExistsError:
|
||||
raise NotFound("Conversation Not Exists.")
|
||||
except services.errors.conversation.ConversationCompletedError:
|
||||
raise ConversationCompletedError()
|
||||
except services.errors.app_model_config.AppModelConfigBrokenError:
|
||||
logger.exception("App model config broken.")
|
||||
raise AppUnavailableError()
|
||||
except ProviderTokenNotInitError as ex:
|
||||
raise ProviderNotInitializeError(ex.description)
|
||||
except QuotaExceededError:
|
||||
raise ProviderQuotaExceededError()
|
||||
except ModelCurrentlyNotSupportError:
|
||||
raise ProviderModelCurrentlyNotSupportError()
|
||||
except InvokeRateLimitError as ex:
|
||||
raise InvokeRateLimitHttpError(ex.description)
|
||||
except InvokeError as e:
|
||||
raise CompletionRequestError(e.description)
|
||||
except ValueError as e:
|
||||
raise e
|
||||
except Exception as e:
|
||||
logger.exception("internal server error.")
|
||||
raise InternalServerError()
|
||||
|
||||
|
||||
def _stop_chat_message(*, current_user_id: str, app_model: App, task_id: str):
|
||||
AppTaskService.stop_task(
|
||||
task_id=task_id,
|
||||
invoke_from=InvokeFrom.DEBUGGER,
|
||||
user_id=current_user_id,
|
||||
app_mode=AppMode.value_of(app_model.mode),
|
||||
)
|
||||
|
||||
return {"result": "success"}, 200
|
||||
|
||||
@@ -13,6 +13,7 @@ from controllers.common.controller_schemas import MessageFeedbackPayload as _Mes
|
||||
from controllers.common.fields import SimpleResultResponse, TextFileResponse
|
||||
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.agent.app_helpers import resolve_agent_app_model
|
||||
from controllers.console.app.error import (
|
||||
CompletionRequestError,
|
||||
ProviderModelCurrentlyNotSupportError,
|
||||
@@ -25,6 +26,7 @@ from controllers.console.wraps import (
|
||||
account_initialization_required,
|
||||
edit_permission_required,
|
||||
setup_required,
|
||||
with_current_tenant_id,
|
||||
with_current_user,
|
||||
)
|
||||
from core.app.entities.app_invoke_entities import InvokeFrom
|
||||
@@ -183,67 +185,25 @@ class ChatMessageListApi(Resource):
|
||||
@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())
|
||||
return _list_chat_messages(app_model=app_model)
|
||||
|
||||
conversation = db.session.scalar(
|
||||
select(Conversation)
|
||||
.where(Conversation.id == args.conversation_id, Conversation.app_id == app_model.id)
|
||||
.limit(1)
|
||||
)
|
||||
|
||||
if not conversation:
|
||||
raise NotFound("Conversation Not Exists.")
|
||||
|
||||
if args.first_id:
|
||||
first_message = db.session.scalar(
|
||||
select(Message).where(Message.conversation_id == conversation.id, Message.id == args.first_id).limit(1)
|
||||
)
|
||||
|
||||
if not first_message:
|
||||
raise NotFound("First message not found")
|
||||
|
||||
history_messages = db.session.scalars(
|
||||
select(Message)
|
||||
.where(
|
||||
Message.conversation_id == conversation.id,
|
||||
Message.created_at < first_message.created_at,
|
||||
Message.id != first_message.id,
|
||||
)
|
||||
.order_by(Message.created_at.desc())
|
||||
.limit(args.limit)
|
||||
).all()
|
||||
else:
|
||||
history_messages = db.session.scalars(
|
||||
select(Message)
|
||||
.where(Message.conversation_id == conversation.id)
|
||||
.order_by(Message.created_at.desc())
|
||||
.limit(args.limit)
|
||||
).all()
|
||||
|
||||
# Initialize has_more based on whether we have a full page
|
||||
if len(history_messages) == args.limit:
|
||||
current_page_first_message = history_messages[-1]
|
||||
# Check if there are more messages before the current page
|
||||
has_more = db.session.scalar(
|
||||
select(
|
||||
exists().where(
|
||||
Message.conversation_id == conversation.id,
|
||||
Message.created_at < current_page_first_message.created_at,
|
||||
Message.id != current_page_first_message.id,
|
||||
)
|
||||
)
|
||||
)
|
||||
else:
|
||||
# If we don't have a full page, there are no more messages
|
||||
has_more = False
|
||||
|
||||
history_messages = list(reversed(history_messages))
|
||||
attach_message_extra_contents(history_messages)
|
||||
|
||||
return MessageInfiniteScrollPaginationResponse.model_validate(
|
||||
InfiniteScrollPagination(data=history_messages, limit=args.limit, has_more=has_more),
|
||||
from_attributes=True,
|
||||
).model_dump(mode="json")
|
||||
@console_ns.route("/agent/<uuid:agent_id>/chat-messages")
|
||||
class AgentChatMessageListApi(Resource):
|
||||
@console_ns.doc("list_agent_chat_messages")
|
||||
@console_ns.doc(description="Get Agent App chat messages for a conversation with pagination")
|
||||
@console_ns.doc(params={"agent_id": "Agent ID"})
|
||||
@console_ns.doc(params=query_params_from_model(ChatMessagesQuery))
|
||||
@console_ns.response(200, "Success", console_ns.models[MessageInfiniteScrollPaginationResponse.__name__])
|
||||
@console_ns.response(404, "Agent or conversation not found")
|
||||
@login_required
|
||||
@account_initialization_required
|
||||
@setup_required
|
||||
@edit_permission_required
|
||||
@with_current_tenant_id
|
||||
def get(self, current_tenant_id: str, agent_id: UUID):
|
||||
app_model = resolve_agent_app_model(tenant_id=current_tenant_id, agent_id=agent_id)
|
||||
return _list_chat_messages(app_model=app_model)
|
||||
|
||||
|
||||
@console_ns.route("/apps/<uuid:app_id>/feedbacks")
|
||||
@@ -261,44 +221,25 @@ class MessageFeedbackApi(Resource):
|
||||
@account_initialization_required
|
||||
@with_current_user
|
||||
def post(self, current_user: Account, app_model: App):
|
||||
args = MessageFeedbackPayload.model_validate(console_ns.payload)
|
||||
return _update_message_feedback(current_user=current_user, app_model=app_model)
|
||||
|
||||
message_id = str(args.message_id)
|
||||
|
||||
message = db.session.scalar(
|
||||
select(Message).where(Message.id == message_id, Message.app_id == app_model.id).limit(1)
|
||||
)
|
||||
|
||||
if not message:
|
||||
raise NotFound("Message Not Exists.")
|
||||
|
||||
feedback = message.admin_feedback
|
||||
|
||||
if not args.rating and feedback:
|
||||
db.session.delete(feedback)
|
||||
elif args.rating and feedback:
|
||||
feedback.rating = FeedbackRating(args.rating)
|
||||
feedback.content = args.content
|
||||
elif not args.rating and not feedback:
|
||||
raise ValueError("rating cannot be None when feedback not exists")
|
||||
else:
|
||||
rating_value = args.rating
|
||||
if rating_value is None:
|
||||
raise ValueError("rating is required to create feedback")
|
||||
feedback = MessageFeedback(
|
||||
app_id=app_model.id,
|
||||
conversation_id=message.conversation_id,
|
||||
message_id=message.id,
|
||||
rating=FeedbackRating(rating_value),
|
||||
content=args.content,
|
||||
from_source=FeedbackFromSource.ADMIN,
|
||||
from_account_id=current_user.id,
|
||||
)
|
||||
db.session.add(feedback)
|
||||
|
||||
db.session.commit()
|
||||
|
||||
return {"result": "success"}
|
||||
@console_ns.route("/agent/<uuid:agent_id>/feedbacks")
|
||||
class AgentMessageFeedbackApi(Resource):
|
||||
@console_ns.doc("create_agent_message_feedback")
|
||||
@console_ns.doc(description="Create or update Agent App message feedback")
|
||||
@console_ns.doc(params={"agent_id": "Agent ID"})
|
||||
@console_ns.expect(console_ns.models[MessageFeedbackPayload.__name__])
|
||||
@console_ns.response(200, "Feedback updated successfully", console_ns.models[SimpleResultResponse.__name__])
|
||||
@console_ns.response(404, "Agent or message not found")
|
||||
@setup_required
|
||||
@login_required
|
||||
@account_initialization_required
|
||||
@with_current_user
|
||||
@with_current_tenant_id
|
||||
def post(self, current_tenant_id: str, current_user: Account, agent_id: UUID):
|
||||
app_model = resolve_agent_app_model(tenant_id=current_tenant_id, agent_id=agent_id)
|
||||
return _update_message_feedback(current_user=current_user, app_model=app_model)
|
||||
|
||||
|
||||
@console_ns.route("/apps/<uuid:app_id>/annotations/count")
|
||||
@@ -340,31 +281,28 @@ class MessageSuggestedQuestionApi(Resource):
|
||||
@get_app_model(mode=[AppMode.CHAT, AppMode.AGENT_CHAT, AppMode.ADVANCED_CHAT, AppMode.AGENT])
|
||||
@with_current_user
|
||||
def get(self, current_user: Account, app_model: App, message_id: UUID):
|
||||
message_id_str = str(message_id)
|
||||
return _get_message_suggested_questions(current_user=current_user, app_model=app_model, message_id=message_id)
|
||||
|
||||
try:
|
||||
questions = MessageService.get_suggested_questions_after_answer(
|
||||
app_model=app_model, message_id=message_id_str, user=current_user, invoke_from=InvokeFrom.DEBUGGER
|
||||
)
|
||||
except MessageNotExistsError:
|
||||
raise NotFound("Message not found")
|
||||
except ConversationNotExistsError:
|
||||
raise NotFound("Conversation not found")
|
||||
except ProviderTokenNotInitError as ex:
|
||||
raise ProviderNotInitializeError(ex.description)
|
||||
except QuotaExceededError:
|
||||
raise ProviderQuotaExceededError()
|
||||
except ModelCurrentlyNotSupportError:
|
||||
raise ProviderModelCurrentlyNotSupportError()
|
||||
except InvokeError as e:
|
||||
raise CompletionRequestError(e.description)
|
||||
except SuggestedQuestionsAfterAnswerDisabledError:
|
||||
raise AppSuggestedQuestionsAfterAnswerDisabledError()
|
||||
except Exception:
|
||||
logger.exception("internal server error.")
|
||||
raise InternalServerError()
|
||||
|
||||
return {"data": questions}
|
||||
@console_ns.route("/agent/<uuid:agent_id>/chat-messages/<uuid:message_id>/suggested-questions")
|
||||
class AgentMessageSuggestedQuestionApi(Resource):
|
||||
@console_ns.doc("get_agent_message_suggested_questions")
|
||||
@console_ns.doc(description="Get suggested questions for an Agent App message")
|
||||
@console_ns.doc(params={"agent_id": "Agent ID", "message_id": "Message ID"})
|
||||
@console_ns.response(
|
||||
200,
|
||||
"Suggested questions retrieved successfully",
|
||||
console_ns.models[SuggestedQuestionsResponse.__name__],
|
||||
)
|
||||
@console_ns.response(404, "Agent, message, or conversation not found")
|
||||
@setup_required
|
||||
@login_required
|
||||
@account_initialization_required
|
||||
@with_current_user
|
||||
@with_current_tenant_id
|
||||
def get(self, current_tenant_id: str, current_user: Account, agent_id: UUID, message_id: UUID):
|
||||
app_model = resolve_agent_app_model(tenant_id=current_tenant_id, agent_id=agent_id)
|
||||
return _get_message_suggested_questions(current_user=current_user, app_model=app_model, message_id=message_id)
|
||||
|
||||
|
||||
@console_ns.route("/apps/<uuid:app_id>/feedbacks/export")
|
||||
@@ -423,14 +361,167 @@ class MessageApi(Resource):
|
||||
@login_required
|
||||
@account_initialization_required
|
||||
def get(self, app_model: App, message_id: UUID):
|
||||
message_id_str = str(message_id)
|
||||
return _get_message_detail(app_model=app_model, message_id=message_id)
|
||||
|
||||
message = db.session.scalar(
|
||||
select(Message).where(Message.id == message_id_str, Message.app_id == app_model.id).limit(1)
|
||||
|
||||
@console_ns.route("/agent/<uuid:agent_id>/messages/<uuid:message_id>")
|
||||
class AgentMessageApi(Resource):
|
||||
@console_ns.doc("get_agent_message")
|
||||
@console_ns.doc(description="Get Agent App message details by ID")
|
||||
@console_ns.doc(params={"agent_id": "Agent ID", "message_id": "Message ID"})
|
||||
@console_ns.response(200, "Message retrieved successfully", console_ns.models[MessageDetailResponse.__name__])
|
||||
@console_ns.response(404, "Agent or message not found")
|
||||
@setup_required
|
||||
@login_required
|
||||
@account_initialization_required
|
||||
@with_current_tenant_id
|
||||
def get(self, current_tenant_id: str, agent_id: UUID, message_id: UUID):
|
||||
app_model = resolve_agent_app_model(tenant_id=current_tenant_id, agent_id=agent_id)
|
||||
return _get_message_detail(app_model=app_model, message_id=message_id)
|
||||
|
||||
|
||||
def _list_chat_messages(*, app_model: App):
|
||||
args = ChatMessagesQuery.model_validate(request.args.to_dict())
|
||||
|
||||
conversation = db.session.scalar(
|
||||
select(Conversation)
|
||||
.where(Conversation.id == args.conversation_id, Conversation.app_id == app_model.id)
|
||||
.limit(1)
|
||||
)
|
||||
|
||||
if not conversation:
|
||||
raise NotFound("Conversation Not Exists.")
|
||||
|
||||
if args.first_id:
|
||||
first_message = db.session.scalar(
|
||||
select(Message).where(Message.conversation_id == conversation.id, Message.id == args.first_id).limit(1)
|
||||
)
|
||||
|
||||
if not message:
|
||||
raise NotFound("Message Not Exists.")
|
||||
if not first_message:
|
||||
raise NotFound("First message not found")
|
||||
|
||||
attach_message_extra_contents([message])
|
||||
return MessageDetailResponse.model_validate(message, from_attributes=True).model_dump(mode="json")
|
||||
history_messages = db.session.scalars(
|
||||
select(Message)
|
||||
.where(
|
||||
Message.conversation_id == conversation.id,
|
||||
Message.created_at < first_message.created_at,
|
||||
Message.id != first_message.id,
|
||||
)
|
||||
.order_by(Message.created_at.desc())
|
||||
.limit(args.limit)
|
||||
).all()
|
||||
else:
|
||||
history_messages = db.session.scalars(
|
||||
select(Message)
|
||||
.where(Message.conversation_id == conversation.id)
|
||||
.order_by(Message.created_at.desc())
|
||||
.limit(args.limit)
|
||||
).all()
|
||||
|
||||
# Initialize has_more based on whether we have a full page
|
||||
if len(history_messages) == args.limit:
|
||||
current_page_first_message = history_messages[-1]
|
||||
# Check if there are more messages before the current page
|
||||
has_more = db.session.scalar(
|
||||
select(
|
||||
exists().where(
|
||||
Message.conversation_id == conversation.id,
|
||||
Message.created_at < current_page_first_message.created_at,
|
||||
Message.id != current_page_first_message.id,
|
||||
)
|
||||
)
|
||||
)
|
||||
else:
|
||||
# If we don't have a full page, there are no more messages
|
||||
has_more = False
|
||||
|
||||
history_messages = list(reversed(history_messages))
|
||||
attach_message_extra_contents(history_messages)
|
||||
|
||||
return MessageInfiniteScrollPaginationResponse.model_validate(
|
||||
InfiniteScrollPagination(data=history_messages, limit=args.limit, has_more=has_more),
|
||||
from_attributes=True,
|
||||
).model_dump(mode="json")
|
||||
|
||||
|
||||
def _update_message_feedback(*, current_user: Account, app_model: App):
|
||||
args = MessageFeedbackPayload.model_validate(console_ns.payload)
|
||||
|
||||
message_id = str(args.message_id)
|
||||
|
||||
message = db.session.scalar(
|
||||
select(Message).where(Message.id == message_id, Message.app_id == app_model.id).limit(1)
|
||||
)
|
||||
|
||||
if not message:
|
||||
raise NotFound("Message Not Exists.")
|
||||
|
||||
feedback = message.admin_feedback
|
||||
|
||||
if not args.rating and feedback:
|
||||
db.session.delete(feedback)
|
||||
elif args.rating and feedback:
|
||||
feedback.rating = FeedbackRating(args.rating)
|
||||
feedback.content = args.content
|
||||
elif not args.rating and not feedback:
|
||||
raise ValueError("rating cannot be None when feedback not exists")
|
||||
else:
|
||||
rating_value = args.rating
|
||||
if rating_value is None:
|
||||
raise ValueError("rating is required to create feedback")
|
||||
feedback = MessageFeedback(
|
||||
app_id=app_model.id,
|
||||
conversation_id=message.conversation_id,
|
||||
message_id=message.id,
|
||||
rating=FeedbackRating(rating_value),
|
||||
content=args.content,
|
||||
from_source=FeedbackFromSource.ADMIN,
|
||||
from_account_id=current_user.id,
|
||||
)
|
||||
db.session.add(feedback)
|
||||
|
||||
db.session.commit()
|
||||
|
||||
return {"result": "success"}
|
||||
|
||||
|
||||
def _get_message_suggested_questions(*, current_user: Account, app_model: App, message_id: UUID):
|
||||
message_id_str = str(message_id)
|
||||
|
||||
try:
|
||||
questions = MessageService.get_suggested_questions_after_answer(
|
||||
app_model=app_model, message_id=message_id_str, user=current_user, invoke_from=InvokeFrom.DEBUGGER
|
||||
)
|
||||
except MessageNotExistsError:
|
||||
raise NotFound("Message not found")
|
||||
except ConversationNotExistsError:
|
||||
raise NotFound("Conversation not found")
|
||||
except ProviderTokenNotInitError as ex:
|
||||
raise ProviderNotInitializeError(ex.description)
|
||||
except QuotaExceededError:
|
||||
raise ProviderQuotaExceededError()
|
||||
except ModelCurrentlyNotSupportError:
|
||||
raise ProviderModelCurrentlyNotSupportError()
|
||||
except InvokeError as e:
|
||||
raise CompletionRequestError(e.description)
|
||||
except SuggestedQuestionsAfterAnswerDisabledError:
|
||||
raise AppSuggestedQuestionsAfterAnswerDisabledError()
|
||||
except Exception:
|
||||
logger.exception("internal server error.")
|
||||
raise InternalServerError()
|
||||
|
||||
return {"data": questions}
|
||||
|
||||
|
||||
def _get_message_detail(*, app_model: App, message_id: UUID):
|
||||
message_id_str = str(message_id)
|
||||
|
||||
message = db.session.scalar(
|
||||
select(Message).where(Message.id == message_id_str, Message.app_id == app_model.id).limit(1)
|
||||
)
|
||||
|
||||
if not message:
|
||||
raise NotFound("Message Not Exists.")
|
||||
|
||||
attach_message_extra_contents([message])
|
||||
return MessageDetailResponse.model_validate(message, from_attributes=True).model_dump(mode="json")
|
||||
|
||||
@@ -392,6 +392,58 @@ Check if activation token is valid
|
||||
| 400 | Invalid request parameters | |
|
||||
| 403 | Insufficient permissions | |
|
||||
|
||||
### [GET] /agent/{agent_id}/chat-messages
|
||||
Get Agent App chat messages for a conversation with pagination
|
||||
|
||||
#### Parameters
|
||||
|
||||
| Name | Located in | Description | Required | Schema |
|
||||
| ---- | ---------- | ----------- | -------- | ------ |
|
||||
| agent_id | path | Agent ID | Yes | string |
|
||||
| conversation_id | query | Conversation ID | Yes | string |
|
||||
| first_id | query | First message ID for pagination | No | string |
|
||||
| limit | query | Number of messages to return (1-100) | No | integer, <br>**Default:** 20 |
|
||||
|
||||
#### Responses
|
||||
|
||||
| Code | Description | Schema |
|
||||
| ---- | ----------- | ------ |
|
||||
| 200 | Success | **application/json**: [MessageInfiniteScrollPaginationResponse](#messageinfinitescrollpaginationresponse)<br> |
|
||||
| 404 | Agent or conversation not found | |
|
||||
|
||||
### [GET] /agent/{agent_id}/chat-messages/{message_id}/suggested-questions
|
||||
Get suggested questions for an Agent App message
|
||||
|
||||
#### Parameters
|
||||
|
||||
| Name | Located in | Description | Required | Schema |
|
||||
| ---- | ---------- | ----------- | -------- | ------ |
|
||||
| agent_id | path | Agent ID | Yes | string |
|
||||
| message_id | path | Message ID | Yes | string |
|
||||
|
||||
#### Responses
|
||||
|
||||
| Code | Description | Schema |
|
||||
| ---- | ----------- | ------ |
|
||||
| 200 | Suggested questions retrieved successfully | **application/json**: [SuggestedQuestionsResponse](#suggestedquestionsresponse)<br> |
|
||||
| 404 | Agent, message, or conversation not found | |
|
||||
|
||||
### [POST] /agent/{agent_id}/chat-messages/{task_id}/stop
|
||||
Stop a running Agent App chat message generation
|
||||
|
||||
#### Parameters
|
||||
|
||||
| Name | Located in | Description | Required | Schema |
|
||||
| ---- | ---------- | ----------- | -------- | ------ |
|
||||
| agent_id | path | Agent ID | Yes | string |
|
||||
| task_id | path | Task ID to stop | Yes | string |
|
||||
|
||||
#### Responses
|
||||
|
||||
| Code | Description | Schema |
|
||||
| ---- | ----------- | ------ |
|
||||
| 200 | Task stopped successfully | **application/json**: [SimpleResultResponse](#simpleresultresponse)<br> |
|
||||
|
||||
### [GET] /agent/{agent_id}/composer
|
||||
#### Parameters
|
||||
|
||||
@@ -527,6 +579,28 @@ Update an Agent App's presentation features (opener, follow-up, citations, ...)
|
||||
| 400 | Invalid configuration | |
|
||||
| 404 | Agent not found | |
|
||||
|
||||
### [POST] /agent/{agent_id}/feedbacks
|
||||
Create or update Agent App message feedback
|
||||
|
||||
#### Parameters
|
||||
|
||||
| Name | Located in | Description | Required | Schema |
|
||||
| ---- | ---------- | ----------- | -------- | ------ |
|
||||
| agent_id | path | Agent ID | Yes | string |
|
||||
|
||||
#### Request Body
|
||||
|
||||
| Required | Schema |
|
||||
| -------- | ------ |
|
||||
| Yes | **application/json**: [MessageFeedbackPayload](#messagefeedbackpayload)<br> |
|
||||
|
||||
#### Responses
|
||||
|
||||
| Code | Description | Schema |
|
||||
| ---- | ----------- | ------ |
|
||||
| 200 | Feedback updated successfully | **application/json**: [SimpleResultResponse](#simpleresultresponse)<br> |
|
||||
| 404 | Agent or message not found | |
|
||||
|
||||
### [DELETE] /agent/{agent_id}/files
|
||||
Delete one Agent App drive file by key
|
||||
|
||||
@@ -564,6 +638,23 @@ Commit an uploaded file into the Agent App drive under files/<name>
|
||||
| ---- | ----------- | ------ |
|
||||
| 201 | File committed into the agent drive | **application/json**: [AgentDriveFileCommitResponse](#agentdrivefilecommitresponse)<br> |
|
||||
|
||||
### [GET] /agent/{agent_id}/messages/{message_id}
|
||||
Get Agent App message details by ID
|
||||
|
||||
#### Parameters
|
||||
|
||||
| Name | Located in | Description | Required | Schema |
|
||||
| ---- | ---------- | ----------- | -------- | ------ |
|
||||
| agent_id | path | Agent ID | Yes | string |
|
||||
| message_id | path | Message ID | Yes | string |
|
||||
|
||||
#### Responses
|
||||
|
||||
| Code | Description | Schema |
|
||||
| ---- | ----------- | ------ |
|
||||
| 200 | Message retrieved successfully | **application/json**: [MessageDetailResponse](#messagedetailresponse)<br> |
|
||||
| 404 | Agent or message not found | |
|
||||
|
||||
### [GET] /agent/{agent_id}/referencing-workflows
|
||||
List workflow apps that reference this Agent App's bound Agent (read-only)
|
||||
|
||||
|
||||
@@ -9,7 +9,14 @@ from sqlalchemy import select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from core.workflow.nodes.agent_v2.validators import WorkflowAgentNodeValidator
|
||||
from models.agent import Agent, AgentScope, AgentStatus, WorkflowAgentBindingType, WorkflowAgentNodeBinding
|
||||
from models.agent import (
|
||||
Agent,
|
||||
AgentConfigSnapshot,
|
||||
AgentScope,
|
||||
AgentStatus,
|
||||
WorkflowAgentBindingType,
|
||||
WorkflowAgentNodeBinding,
|
||||
)
|
||||
from models.agent_config_entities import DeclaredOutputConfig, WorkflowNodeJobConfig
|
||||
from models.workflow import Workflow
|
||||
|
||||
@@ -66,7 +73,7 @@ class WorkflowAgentPublishService:
|
||||
WorkflowAgentNodeValidator.validate_draft_workflow(session=session, workflow=draft_workflow)
|
||||
|
||||
@classmethod
|
||||
def sync_roster_agent_bindings_for_draft(
|
||||
def sync_agent_bindings_for_draft(
|
||||
cls,
|
||||
*,
|
||||
session: Session,
|
||||
@@ -96,7 +103,7 @@ class WorkflowAgentPublishService:
|
||||
continue
|
||||
if not isinstance(binding_payload, Mapping):
|
||||
raise ValueError(f"Workflow Agent node {node_id} has invalid agent_binding.")
|
||||
cls._sync_roster_agent_binding_for_node(
|
||||
cls._sync_agent_binding_for_node(
|
||||
session=session,
|
||||
draft_workflow=draft_workflow,
|
||||
node_id=node_id,
|
||||
@@ -108,7 +115,21 @@ class WorkflowAgentPublishService:
|
||||
session.flush()
|
||||
|
||||
@classmethod
|
||||
def _sync_roster_agent_binding_for_node(
|
||||
def sync_roster_agent_bindings_for_draft(
|
||||
cls,
|
||||
*,
|
||||
session: Session,
|
||||
draft_workflow: Workflow,
|
||||
account_id: str,
|
||||
) -> None:
|
||||
cls.sync_agent_bindings_for_draft(
|
||||
session=session,
|
||||
draft_workflow=draft_workflow,
|
||||
account_id=account_id,
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def _sync_agent_binding_for_node(
|
||||
cls,
|
||||
*,
|
||||
session: Session,
|
||||
@@ -120,26 +141,33 @@ class WorkflowAgentPublishService:
|
||||
account_id: str,
|
||||
) -> None:
|
||||
binding_type = node_binding.get("binding_type")
|
||||
if binding_type != WorkflowAgentBindingType.ROSTER_AGENT.value:
|
||||
raise ValueError(f"Workflow Agent node {node_id} only supports roster_agent graph binding.")
|
||||
agent_id = node_binding.get("agent_id")
|
||||
if not isinstance(agent_id, str) or not agent_id:
|
||||
raise ValueError(f"Workflow Agent node {node_id} roster_agent binding requires agent_id.")
|
||||
raise ValueError(f"Workflow Agent node {node_id} agent binding requires agent_id.")
|
||||
|
||||
agent = session.scalar(
|
||||
select(Agent)
|
||||
.where(
|
||||
Agent.tenant_id == draft_workflow.tenant_id,
|
||||
Agent.id == agent_id,
|
||||
Agent.scope == AgentScope.ROSTER,
|
||||
Agent.status == AgentStatus.ACTIVE,
|
||||
if binding_type == WorkflowAgentBindingType.ROSTER_AGENT.value:
|
||||
agent, current_snapshot_id = cls._resolve_roster_agent_graph_binding(
|
||||
session=session,
|
||||
draft_workflow=draft_workflow,
|
||||
node_id=node_id,
|
||||
agent_id=agent_id,
|
||||
)
|
||||
.limit(1)
|
||||
)
|
||||
if agent is None:
|
||||
raise ValueError(f"Workflow Agent node {node_id} references an unavailable roster agent.")
|
||||
if not agent.active_config_snapshot_id:
|
||||
raise ValueError(f"Workflow Agent node {node_id} roster agent has no active config snapshot.")
|
||||
resolved_binding_type = WorkflowAgentBindingType.ROSTER_AGENT
|
||||
elif binding_type == WorkflowAgentBindingType.INLINE_AGENT.value:
|
||||
raw_current_snapshot_id = node_binding.get("current_snapshot_id")
|
||||
if not isinstance(raw_current_snapshot_id, str) or not raw_current_snapshot_id:
|
||||
raise ValueError(f"Workflow Agent node {node_id} inline_agent binding requires current_snapshot_id.")
|
||||
current_snapshot_id = raw_current_snapshot_id
|
||||
agent = cls._resolve_inline_agent_graph_binding(
|
||||
session=session,
|
||||
draft_workflow=draft_workflow,
|
||||
node_id=node_id,
|
||||
agent_id=agent_id,
|
||||
current_snapshot_id=current_snapshot_id,
|
||||
)
|
||||
resolved_binding_type = WorkflowAgentBindingType.INLINE_AGENT
|
||||
else:
|
||||
raise ValueError(f"Workflow Agent node {node_id} has unsupported agent_binding type.")
|
||||
|
||||
binding = existing_binding
|
||||
node_job_config = cls._node_job_config_from_node_data(
|
||||
@@ -160,11 +188,84 @@ class WorkflowAgentPublishService:
|
||||
else:
|
||||
binding.node_job_config = node_job_config
|
||||
|
||||
binding.binding_type = WorkflowAgentBindingType.ROSTER_AGENT
|
||||
binding.binding_type = resolved_binding_type
|
||||
binding.agent_id = agent.id
|
||||
binding.current_snapshot_id = agent.active_config_snapshot_id
|
||||
binding.current_snapshot_id = current_snapshot_id
|
||||
binding.updated_by = account_id
|
||||
|
||||
@classmethod
|
||||
def _resolve_roster_agent_graph_binding(
|
||||
cls,
|
||||
*,
|
||||
session: Session,
|
||||
draft_workflow: Workflow,
|
||||
node_id: str,
|
||||
agent_id: str,
|
||||
) -> tuple[Agent, str]:
|
||||
agent = session.scalar(
|
||||
select(Agent)
|
||||
.where(
|
||||
Agent.tenant_id == draft_workflow.tenant_id,
|
||||
Agent.id == agent_id,
|
||||
Agent.scope == AgentScope.ROSTER,
|
||||
Agent.status == AgentStatus.ACTIVE,
|
||||
)
|
||||
.limit(1)
|
||||
)
|
||||
if agent is None:
|
||||
raise ValueError(f"Workflow Agent node {node_id} references an unavailable roster agent.")
|
||||
if agent.scope != AgentScope.ROSTER:
|
||||
raise ValueError(f"Workflow Agent node {node_id} roster_agent binding must reference a roster agent.")
|
||||
if not agent.active_config_snapshot_id:
|
||||
raise ValueError(f"Workflow Agent node {node_id} roster agent has no active config snapshot.")
|
||||
return agent, agent.active_config_snapshot_id
|
||||
|
||||
@classmethod
|
||||
def _resolve_inline_agent_graph_binding(
|
||||
cls,
|
||||
*,
|
||||
session: Session,
|
||||
draft_workflow: Workflow,
|
||||
node_id: str,
|
||||
agent_id: str,
|
||||
current_snapshot_id: str,
|
||||
) -> Agent:
|
||||
agent = session.scalar(
|
||||
select(Agent)
|
||||
.where(
|
||||
Agent.tenant_id == draft_workflow.tenant_id,
|
||||
Agent.id == agent_id,
|
||||
Agent.scope == AgentScope.WORKFLOW_ONLY,
|
||||
Agent.app_id == draft_workflow.app_id,
|
||||
Agent.workflow_id == draft_workflow.id,
|
||||
Agent.workflow_node_id == node_id,
|
||||
Agent.status == AgentStatus.ACTIVE,
|
||||
)
|
||||
.limit(1)
|
||||
)
|
||||
if agent is None:
|
||||
raise ValueError(f"Workflow Agent node {node_id} references an unavailable inline agent.")
|
||||
if (
|
||||
agent.scope != AgentScope.WORKFLOW_ONLY
|
||||
or agent.app_id != draft_workflow.app_id
|
||||
or agent.workflow_id != draft_workflow.id
|
||||
or agent.workflow_node_id != node_id
|
||||
):
|
||||
raise ValueError(f"Workflow Agent node {node_id} inline_agent binding does not belong to this node.")
|
||||
|
||||
snapshot = session.scalar(
|
||||
select(AgentConfigSnapshot)
|
||||
.where(
|
||||
AgentConfigSnapshot.tenant_id == draft_workflow.tenant_id,
|
||||
AgentConfigSnapshot.agent_id == agent.id,
|
||||
AgentConfigSnapshot.id == current_snapshot_id,
|
||||
)
|
||||
.limit(1)
|
||||
)
|
||||
if snapshot is None or snapshot.agent_id != agent.id:
|
||||
raise ValueError(f"Workflow Agent node {node_id} references a missing inline agent config snapshot.")
|
||||
return agent
|
||||
|
||||
@classmethod
|
||||
def _node_job_config_from_node_data(
|
||||
cls,
|
||||
|
||||
@@ -323,7 +323,7 @@ class WorkflowService:
|
||||
from services.agent.workflow_publish_service import WorkflowAgentPublishService
|
||||
|
||||
db.session.flush()
|
||||
WorkflowAgentPublishService.sync_roster_agent_bindings_for_draft(
|
||||
WorkflowAgentPublishService.sync_agent_bindings_for_draft(
|
||||
session=cast(Session, db.session),
|
||||
draft_workflow=workflow,
|
||||
account_id=account.id,
|
||||
|
||||
@@ -4,6 +4,7 @@ from typing import Any, cast
|
||||
|
||||
import pytest
|
||||
from flask import Flask
|
||||
from werkzeug.exceptions import InternalServerError, NotFound
|
||||
|
||||
from controllers.console import console_ns
|
||||
from controllers.console.agent import composer as composer_controller
|
||||
@@ -25,6 +26,15 @@ from controllers.console.agent.roster import (
|
||||
AgentRosterVersionDetailApi,
|
||||
AgentRosterVersionsApi,
|
||||
)
|
||||
from controllers.console.app import completion as completion_controller
|
||||
from controllers.console.app import message as message_controller
|
||||
from controllers.console.app.completion import AgentChatMessageApi, AgentChatMessageStopApi
|
||||
from controllers.console.app.message import (
|
||||
AgentChatMessageListApi,
|
||||
AgentMessageApi,
|
||||
AgentMessageFeedbackApi,
|
||||
AgentMessageSuggestedQuestionApi,
|
||||
)
|
||||
from services.entities.agent_entities import ComposerSaveStrategy, ComposerVariant
|
||||
|
||||
|
||||
@@ -132,6 +142,11 @@ def test_agent_v2_console_routes_are_agent_id_first() -> None:
|
||||
"/agent/<uuid:agent_id>/sandbox/files",
|
||||
"/agent/<uuid:agent_id>/skills/upload",
|
||||
"/agent/<uuid:agent_id>/files",
|
||||
"/agent/<uuid:agent_id>/chat-messages",
|
||||
"/agent/<uuid:agent_id>/chat-messages/<string:task_id>/stop",
|
||||
"/agent/<uuid:agent_id>/feedbacks",
|
||||
"/agent/<uuid:agent_id>/chat-messages/<uuid:message_id>/suggested-questions",
|
||||
"/agent/<uuid:agent_id>/messages/<uuid:message_id>",
|
||||
"/agent/invite-options",
|
||||
):
|
||||
assert route in paths
|
||||
@@ -471,6 +486,272 @@ def test_agent_composer_routes_resolve_app_from_agent_id(
|
||||
assert cast(dict[str, object], captured["candidates"])["app_id"] == "app-1"
|
||||
|
||||
|
||||
def test_agent_chat_generate_and_stop_routes_resolve_app_from_agent_id(
|
||||
app: Flask, monkeypatch: pytest.MonkeyPatch, account_id: str
|
||||
) -> None:
|
||||
agent_id = "00000000-0000-0000-0000-000000000001"
|
||||
app_model = SimpleNamespace(id="app-1", mode="agent")
|
||||
captured: dict[str, object] = {}
|
||||
|
||||
def resolve_agent_app_model(**kwargs: object) -> object:
|
||||
captured["resolve"] = kwargs
|
||||
return app_model
|
||||
|
||||
def create_chat_message(**kwargs: object) -> dict[str, object]:
|
||||
captured["create"] = kwargs
|
||||
return {"result": "generated"}
|
||||
|
||||
def stop_chat_message(**kwargs: object) -> tuple[dict[str, object], int]:
|
||||
captured["stop"] = kwargs
|
||||
return {"result": "success"}, 200
|
||||
|
||||
monkeypatch.setattr(completion_controller, "resolve_agent_app_model", resolve_agent_app_model)
|
||||
monkeypatch.setattr(completion_controller, "_create_chat_message", create_chat_message)
|
||||
monkeypatch.setattr(completion_controller, "_stop_chat_message", stop_chat_message)
|
||||
|
||||
with app.test_request_context(json={"inputs": {}, "query": "hello"}):
|
||||
assert unwrap(AgentChatMessageApi.post)(
|
||||
AgentChatMessageApi(), "tenant-1", SimpleNamespace(id=account_id), agent_id
|
||||
) == {"result": "generated"}
|
||||
|
||||
assert cast(dict[str, object], captured["resolve"]) == {"tenant_id": "tenant-1", "agent_id": agent_id}
|
||||
create_call = cast(dict[str, object], captured["create"])
|
||||
assert create_call["app_model"] is app_model
|
||||
assert cast(SimpleNamespace, create_call["current_user"]).id == account_id
|
||||
|
||||
assert unwrap(AgentChatMessageStopApi.post)(
|
||||
AgentChatMessageStopApi(), "tenant-1", account_id, agent_id, "task-1"
|
||||
) == ({"result": "success"}, 200)
|
||||
stop_call = cast(dict[str, object], captured["stop"])
|
||||
assert stop_call == {"current_user_id": account_id, "app_model": app_model, "task_id": "task-1"}
|
||||
|
||||
|
||||
def test_agent_chat_helper_forces_agent_streaming_and_external_trace(
|
||||
app: Flask, monkeypatch: pytest.MonkeyPatch, account_id: str
|
||||
) -> None:
|
||||
app_model = SimpleNamespace(id="app-1", mode="agent")
|
||||
current_user = SimpleNamespace(id=account_id)
|
||||
captured: dict[str, object] = {}
|
||||
|
||||
def generate(**kwargs: object) -> dict[str, object]:
|
||||
captured.update(kwargs)
|
||||
return {"answer": "ok"}
|
||||
|
||||
monkeypatch.setattr(completion_controller.AppGenerateService, "generate", generate)
|
||||
monkeypatch.setattr(
|
||||
completion_controller.helper,
|
||||
"compact_generate_response",
|
||||
lambda response: {"response": response},
|
||||
)
|
||||
|
||||
with app.test_request_context(
|
||||
json={"inputs": {}, "query": "hello", "response_mode": "streaming"},
|
||||
headers={"X-Trace-Id": "trace-1"},
|
||||
):
|
||||
result = completion_controller._create_chat_message(current_user=current_user, app_model=app_model)
|
||||
|
||||
assert result == {"response": {"answer": "ok"}}
|
||||
assert captured["app_model"] is app_model
|
||||
assert captured["user"] is current_user
|
||||
assert captured["streaming"] is True
|
||||
args = cast(dict[str, object], captured["args"])
|
||||
assert args["response_mode"] == "streaming"
|
||||
assert args["auto_generate_name"] is False
|
||||
assert args["external_trace_id"] == "trace-1"
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("error", "expected"),
|
||||
[
|
||||
(completion_controller.services.errors.conversation.ConversationNotExistsError(), NotFound),
|
||||
(
|
||||
completion_controller.services.errors.conversation.ConversationCompletedError(),
|
||||
completion_controller.ConversationCompletedError,
|
||||
),
|
||||
(
|
||||
completion_controller.services.errors.app_model_config.AppModelConfigBrokenError(),
|
||||
completion_controller.AppUnavailableError,
|
||||
),
|
||||
(
|
||||
completion_controller.ProviderTokenNotInitError("not initialized"),
|
||||
completion_controller.ProviderNotInitializeError,
|
||||
),
|
||||
(completion_controller.QuotaExceededError(), completion_controller.ProviderQuotaExceededError),
|
||||
(
|
||||
completion_controller.ModelCurrentlyNotSupportError(),
|
||||
completion_controller.ProviderModelCurrentlyNotSupportError,
|
||||
),
|
||||
(completion_controller.InvokeRateLimitError("rate limited"), completion_controller.InvokeRateLimitHttpError),
|
||||
(completion_controller.InvokeError("invoke failed"), completion_controller.CompletionRequestError),
|
||||
(RuntimeError("unexpected"), InternalServerError),
|
||||
],
|
||||
)
|
||||
def test_agent_chat_helper_maps_generation_errors(
|
||||
app: Flask,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
error: Exception,
|
||||
expected: type[Exception],
|
||||
) -> None:
|
||||
app_model = SimpleNamespace(id="app-1", mode="chat")
|
||||
monkeypatch.setattr(completion_controller.AppGenerateService, "generate", lambda **_: (_ for _ in ()).throw(error))
|
||||
|
||||
with app.test_request_context(json={"inputs": {}, "query": "hello"}):
|
||||
with pytest.raises(expected):
|
||||
completion_controller._create_chat_message(
|
||||
current_user=SimpleNamespace(id="account-1"),
|
||||
app_model=app_model,
|
||||
)
|
||||
|
||||
|
||||
def test_agent_chat_message_routes_resolve_app_from_agent_id(app: Flask, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
agent_id = "00000000-0000-0000-0000-000000000001"
|
||||
message_id = "00000000-0000-0000-0000-000000000002"
|
||||
app_model = SimpleNamespace(id="app-1")
|
||||
current_user = SimpleNamespace(id="account-1")
|
||||
captured: dict[str, object] = {}
|
||||
|
||||
def resolve_agent_app_model(**kwargs: object) -> object:
|
||||
captured["resolve"] = kwargs
|
||||
return app_model
|
||||
|
||||
def list_chat_messages(**kwargs: object) -> dict[str, object]:
|
||||
captured["list"] = kwargs
|
||||
return {"data": []}
|
||||
|
||||
def update_message_feedback(**kwargs: object) -> dict[str, object]:
|
||||
captured["feedback"] = kwargs
|
||||
return {"result": "success"}
|
||||
|
||||
def get_message_suggested_questions(**kwargs: object) -> dict[str, object]:
|
||||
captured["suggested"] = kwargs
|
||||
return {"data": ["next"]}
|
||||
|
||||
def get_message_detail(**kwargs: object) -> dict[str, object]:
|
||||
captured["detail"] = kwargs
|
||||
return {"id": message_id}
|
||||
|
||||
monkeypatch.setattr(message_controller, "resolve_agent_app_model", resolve_agent_app_model)
|
||||
monkeypatch.setattr(message_controller, "_list_chat_messages", list_chat_messages)
|
||||
monkeypatch.setattr(message_controller, "_update_message_feedback", update_message_feedback)
|
||||
monkeypatch.setattr(message_controller, "_get_message_suggested_questions", get_message_suggested_questions)
|
||||
monkeypatch.setattr(message_controller, "_get_message_detail", get_message_detail)
|
||||
|
||||
assert unwrap(AgentChatMessageListApi.get)(AgentChatMessageListApi(), "tenant-1", agent_id) == {"data": []}
|
||||
assert cast(dict[str, object], captured["list"])["app_model"] is app_model
|
||||
|
||||
with app.test_request_context(json={"message_id": message_id, "rating": "like"}):
|
||||
assert unwrap(AgentMessageFeedbackApi.post)(AgentMessageFeedbackApi(), "tenant-1", current_user, agent_id) == {
|
||||
"result": "success"
|
||||
}
|
||||
feedback_call = cast(dict[str, object], captured["feedback"])
|
||||
assert feedback_call["app_model"] is app_model
|
||||
assert feedback_call["current_user"] is current_user
|
||||
|
||||
assert unwrap(AgentMessageSuggestedQuestionApi.get)(
|
||||
AgentMessageSuggestedQuestionApi(), "tenant-1", current_user, agent_id, message_id
|
||||
) == {"data": ["next"]}
|
||||
suggested_call = cast(dict[str, object], captured["suggested"])
|
||||
assert suggested_call["app_model"] is app_model
|
||||
assert suggested_call["current_user"] is current_user
|
||||
assert suggested_call["message_id"] == message_id
|
||||
|
||||
assert unwrap(AgentMessageApi.get)(AgentMessageApi(), "tenant-1", agent_id, message_id) == {"id": message_id}
|
||||
detail_call = cast(dict[str, object], captured["detail"])
|
||||
assert detail_call == {"app_model": app_model, "message_id": message_id}
|
||||
|
||||
|
||||
def test_list_chat_messages_supports_first_id_pagination(app: Flask, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
conversation_id = "00000000-0000-0000-0000-000000000010"
|
||||
first_message_id = "00000000-0000-0000-0000-000000000011"
|
||||
older_message_id = "00000000-0000-0000-0000-000000000012"
|
||||
conversation = SimpleNamespace(id=conversation_id)
|
||||
first_message = SimpleNamespace(id=first_message_id, created_at=2)
|
||||
older_message = SimpleNamespace(id=older_message_id, created_at=1)
|
||||
scalar_values = iter([conversation, first_message, True])
|
||||
scalars_result = SimpleNamespace(all=lambda: [older_message])
|
||||
session = SimpleNamespace(
|
||||
scalar=lambda _stmt: next(scalar_values),
|
||||
scalars=lambda _stmt: scalars_result,
|
||||
)
|
||||
|
||||
class FakeMessagePaginationResponse:
|
||||
@classmethod
|
||||
def model_validate(cls, pagination: object, from_attributes: bool = False) -> object:
|
||||
return SimpleNamespace(
|
||||
model_dump=lambda mode: {
|
||||
"data": [item.id for item in pagination.data],
|
||||
"limit": pagination.limit,
|
||||
"has_more": pagination.has_more,
|
||||
}
|
||||
)
|
||||
|
||||
monkeypatch.setattr(message_controller, "db", SimpleNamespace(session=session))
|
||||
monkeypatch.setattr(message_controller, "attach_message_extra_contents", lambda messages: None)
|
||||
monkeypatch.setattr(message_controller, "MessageInfiniteScrollPaginationResponse", FakeMessagePaginationResponse)
|
||||
|
||||
with app.test_request_context(
|
||||
"/console/api/agent/agent-1/chat-messages"
|
||||
f"?conversation_id={conversation_id}&first_id={first_message_id}&limit=1"
|
||||
):
|
||||
result = message_controller._list_chat_messages(app_model=SimpleNamespace(id="app-1"))
|
||||
|
||||
assert result == {"data": [older_message_id], "limit": 1, "has_more": True}
|
||||
|
||||
|
||||
def test_update_message_feedback_rejects_empty_rating_without_existing_feedback(
|
||||
app: Flask, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
message_id = "00000000-0000-0000-0000-000000000002"
|
||||
message = SimpleNamespace(id=message_id, app_id="app-1", admin_feedback=None)
|
||||
session = SimpleNamespace(scalar=lambda _stmt: message)
|
||||
monkeypatch.setattr(message_controller, "db", SimpleNamespace(session=session))
|
||||
|
||||
with app.test_request_context(json={"message_id": message_id, "rating": None}):
|
||||
with pytest.raises(ValueError, match="rating cannot be None"):
|
||||
message_controller._update_message_feedback(
|
||||
current_user=SimpleNamespace(id="account-1"),
|
||||
app_model=SimpleNamespace(id="app-1"),
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("error", "expected"),
|
||||
[
|
||||
(message_controller.MessageNotExistsError(), NotFound),
|
||||
(message_controller.ConversationNotExistsError(), NotFound),
|
||||
(
|
||||
message_controller.ProviderTokenNotInitError("not initialized"),
|
||||
message_controller.ProviderNotInitializeError,
|
||||
),
|
||||
(message_controller.QuotaExceededError(), message_controller.ProviderQuotaExceededError),
|
||||
(message_controller.ModelCurrentlyNotSupportError(), message_controller.ProviderModelCurrentlyNotSupportError),
|
||||
(message_controller.InvokeError("invoke failed"), message_controller.CompletionRequestError),
|
||||
(
|
||||
message_controller.SuggestedQuestionsAfterAnswerDisabledError(),
|
||||
message_controller.AppSuggestedQuestionsAfterAnswerDisabledError,
|
||||
),
|
||||
(RuntimeError("unexpected"), InternalServerError),
|
||||
],
|
||||
)
|
||||
def test_get_message_suggested_questions_maps_service_errors(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
error: Exception,
|
||||
expected: type[Exception],
|
||||
) -> None:
|
||||
monkeypatch.setattr(
|
||||
message_controller.MessageService,
|
||||
"get_suggested_questions_after_answer",
|
||||
lambda **_: (_ for _ in ()).throw(error),
|
||||
)
|
||||
|
||||
with pytest.raises(expected):
|
||||
message_controller._get_message_suggested_questions(
|
||||
current_user=SimpleNamespace(id="account-1"),
|
||||
app_model=SimpleNamespace(id="app-1"),
|
||||
message_id="00000000-0000-0000-0000-000000000002",
|
||||
)
|
||||
|
||||
|
||||
def test_dify_tool_candidate_response_keeps_granularity_fields():
|
||||
"""Both selection granularities must survive the fields-layer model —
|
||||
the frontend needs granularity/tools_count to render the Tools menu."""
|
||||
|
||||
@@ -1261,6 +1261,225 @@ class TestWorkflowAgentDraftBindingSync:
|
||||
],
|
||||
).model_dump(mode="json")
|
||||
|
||||
def test_creates_inline_binding_from_agent_node_graph(self):
|
||||
workflow = Workflow(
|
||||
id="workflow-1",
|
||||
tenant_id="tenant-1",
|
||||
app_id="app-1",
|
||||
version=Workflow.VERSION_DRAFT,
|
||||
graph=json.dumps(
|
||||
{
|
||||
"nodes": [
|
||||
{
|
||||
"id": "agent-node",
|
||||
"data": {
|
||||
"type": "agent",
|
||||
"version": "2",
|
||||
"agent_task": "Use the current node context.",
|
||||
"agent_binding": {
|
||||
"binding_type": "inline_agent",
|
||||
"agent_id": "inline-agent-1",
|
||||
"current_snapshot_id": "inline-snapshot-1",
|
||||
},
|
||||
},
|
||||
}
|
||||
]
|
||||
}
|
||||
),
|
||||
)
|
||||
agent = Agent(
|
||||
id="inline-agent-1",
|
||||
tenant_id="tenant-1",
|
||||
name="Workflow Agent agent-node",
|
||||
agent_kind=AgentKind.DIFY_AGENT,
|
||||
scope=AgentScope.WORKFLOW_ONLY,
|
||||
source=AgentSource.WORKFLOW,
|
||||
app_id="app-1",
|
||||
workflow_id="workflow-1",
|
||||
workflow_node_id="agent-node",
|
||||
status=AgentStatus.ACTIVE,
|
||||
active_config_snapshot_id="inline-snapshot-1",
|
||||
)
|
||||
snapshot = AgentConfigSnapshot(
|
||||
id="inline-snapshot-1",
|
||||
tenant_id="tenant-1",
|
||||
agent_id="inline-agent-1",
|
||||
version=1,
|
||||
config_snapshot=AgentSoulConfig(),
|
||||
)
|
||||
session = FakeSession(scalar=[agent, snapshot], scalars=[[]])
|
||||
|
||||
WorkflowAgentPublishService.sync_agent_bindings_for_draft(
|
||||
session=session,
|
||||
draft_workflow=workflow,
|
||||
account_id="account-1",
|
||||
)
|
||||
|
||||
binding = next(item for item in session.added if isinstance(item, WorkflowAgentNodeBinding))
|
||||
assert binding.binding_type == WorkflowAgentBindingType.INLINE_AGENT
|
||||
assert binding.agent_id == "inline-agent-1"
|
||||
assert binding.current_snapshot_id == "inline-snapshot-1"
|
||||
assert binding.node_job_config_dict == WorkflowNodeJobConfig(
|
||||
workflow_prompt="Use the current node context.",
|
||||
).model_dump(mode="json")
|
||||
|
||||
def test_rejects_inline_binding_for_agent_owned_by_another_node(self):
|
||||
workflow = Workflow(
|
||||
id="workflow-1",
|
||||
tenant_id="tenant-1",
|
||||
app_id="app-1",
|
||||
version=Workflow.VERSION_DRAFT,
|
||||
graph=json.dumps(
|
||||
{
|
||||
"nodes": [
|
||||
{
|
||||
"id": "agent-node",
|
||||
"data": {
|
||||
"type": "agent",
|
||||
"version": "2",
|
||||
"agent_binding": {
|
||||
"binding_type": "inline_agent",
|
||||
"agent_id": "inline-agent-1",
|
||||
"current_snapshot_id": "inline-snapshot-1",
|
||||
},
|
||||
},
|
||||
}
|
||||
]
|
||||
}
|
||||
),
|
||||
)
|
||||
agent = Agent(
|
||||
id="inline-agent-1",
|
||||
tenant_id="tenant-1",
|
||||
name="Workflow Agent other-node",
|
||||
agent_kind=AgentKind.DIFY_AGENT,
|
||||
scope=AgentScope.WORKFLOW_ONLY,
|
||||
source=AgentSource.WORKFLOW,
|
||||
app_id="app-1",
|
||||
workflow_id="workflow-1",
|
||||
workflow_node_id="other-node",
|
||||
status=AgentStatus.ACTIVE,
|
||||
active_config_snapshot_id="inline-snapshot-1",
|
||||
)
|
||||
session = FakeSession(scalar=[agent], scalars=[[]])
|
||||
|
||||
with pytest.raises(ValueError, match="inline_agent binding does not belong to this node"):
|
||||
WorkflowAgentPublishService.sync_agent_bindings_for_draft(
|
||||
session=session,
|
||||
draft_workflow=workflow,
|
||||
account_id="account-1",
|
||||
)
|
||||
|
||||
def test_rejects_agent_node_graph_binding_with_unsupported_type(self):
|
||||
workflow = Workflow(
|
||||
id="workflow-1",
|
||||
tenant_id="tenant-1",
|
||||
app_id="app-1",
|
||||
version=Workflow.VERSION_DRAFT,
|
||||
graph=json.dumps(
|
||||
{
|
||||
"nodes": [
|
||||
{
|
||||
"id": "agent-node",
|
||||
"data": {
|
||||
"type": "agent",
|
||||
"version": "2",
|
||||
"agent_binding": {
|
||||
"binding_type": "unknown",
|
||||
"agent_id": "agent-1",
|
||||
},
|
||||
},
|
||||
}
|
||||
]
|
||||
}
|
||||
),
|
||||
)
|
||||
|
||||
with pytest.raises(ValueError, match="unsupported agent_binding type"):
|
||||
WorkflowAgentPublishService.sync_agent_bindings_for_draft(
|
||||
session=FakeSession(scalars=[[]]),
|
||||
draft_workflow=workflow,
|
||||
account_id="account-1",
|
||||
)
|
||||
|
||||
def test_rejects_inline_binding_without_current_snapshot_id(self):
|
||||
workflow = Workflow(
|
||||
id="workflow-1",
|
||||
tenant_id="tenant-1",
|
||||
app_id="app-1",
|
||||
version=Workflow.VERSION_DRAFT,
|
||||
graph=json.dumps(
|
||||
{
|
||||
"nodes": [
|
||||
{
|
||||
"id": "agent-node",
|
||||
"data": {
|
||||
"type": "agent",
|
||||
"version": "2",
|
||||
"agent_binding": {
|
||||
"binding_type": "inline_agent",
|
||||
"agent_id": "inline-agent-1",
|
||||
},
|
||||
},
|
||||
}
|
||||
]
|
||||
}
|
||||
),
|
||||
)
|
||||
|
||||
with pytest.raises(ValueError, match="inline_agent binding requires current_snapshot_id"):
|
||||
WorkflowAgentPublishService.sync_agent_bindings_for_draft(
|
||||
session=FakeSession(scalars=[[]]),
|
||||
draft_workflow=workflow,
|
||||
account_id="account-1",
|
||||
)
|
||||
|
||||
def test_rejects_inline_binding_with_missing_snapshot(self):
|
||||
workflow = Workflow(
|
||||
id="workflow-1",
|
||||
tenant_id="tenant-1",
|
||||
app_id="app-1",
|
||||
version=Workflow.VERSION_DRAFT,
|
||||
graph=json.dumps(
|
||||
{
|
||||
"nodes": [
|
||||
{
|
||||
"id": "agent-node",
|
||||
"data": {
|
||||
"type": "agent",
|
||||
"version": "2",
|
||||
"agent_binding": {
|
||||
"binding_type": "inline_agent",
|
||||
"agent_id": "inline-agent-1",
|
||||
"current_snapshot_id": "missing-snapshot",
|
||||
},
|
||||
},
|
||||
}
|
||||
]
|
||||
}
|
||||
),
|
||||
)
|
||||
agent = Agent(
|
||||
id="inline-agent-1",
|
||||
tenant_id="tenant-1",
|
||||
name="Workflow Agent agent-node",
|
||||
agent_kind=AgentKind.DIFY_AGENT,
|
||||
scope=AgentScope.WORKFLOW_ONLY,
|
||||
source=AgentSource.WORKFLOW,
|
||||
app_id="app-1",
|
||||
workflow_id="workflow-1",
|
||||
workflow_node_id="agent-node",
|
||||
status=AgentStatus.ACTIVE,
|
||||
active_config_snapshot_id="inline-snapshot-1",
|
||||
)
|
||||
|
||||
with pytest.raises(ValueError, match="missing inline agent config snapshot"):
|
||||
WorkflowAgentPublishService.sync_agent_bindings_for_draft(
|
||||
session=FakeSession(scalar=[agent, None], scalars=[[]]),
|
||||
draft_workflow=workflow,
|
||||
account_id="account-1",
|
||||
)
|
||||
|
||||
def test_updates_existing_roster_binding_prompt_from_agent_node_graph(self):
|
||||
workflow = Workflow(
|
||||
id="workflow-1",
|
||||
|
||||
Reference in New Issue
Block a user