mirror of
https://github.com/langgenius/dify-plugin-sdks.git
synced 2026-07-22 10:25:23 -04:00
c6f83a63e1
* chore: fix ruff issue * feat(oauth): implement OAuth * feat(invoke-message): refactor message handling and introduce InvokeMessage class * feat(plugin-oauth): add credential_id and credential_type to tool parameters * feat(plugin-oauth): add credential_id and credential_type to tool parameters * chore: update dify_plugin version to 0.5.0b4 and clean up github.yaml * chore: update plugin version to 0.1.2 in manifest.yaml * feat(session): session context and tool backwards invocation credential support * feat(oauth): session context and tool backwards invocation credential support * feat: update README and requirements for OAuth support in version 0.4.2 * feat: add .gitignore to exclude IDE files and secret keys * chore: apply ruff * feat: bump version to 0.4.2b1 * feat: update GitHub plugin configuration for OAuth support and improve credential handling * feat: update .gitignore to exclude dify plugin files and public keys * feat: fix credential validation for GitHub API and bump version to 0.2.1 * feat: update GitHub plugin to support multiple access tokens and bump version to 0.2.5 * chore: apply ruff * feat: add ToolProviderOAuthError for improved OAuth error handling in GitHub plugin * chore: apply ruff * chore: bump version to 0.4.2 * chore: update examples sdk version to 0.4.2 * fix: thread deadlock in PluginRunner when running tests without gevent monkey patching * feat: add support for refreshing OAuth credentials in Plugin and GitHub provider * feat: refactor OAuth credential handling to return structured OAuthCredentials object * apply ruff * feat: refactor OAuth credential handling to use ToolOAuthCredentials for improved structure * feat: reorganize imports in __init__.py for improved clarity and structure * feat: add Microsoft To Do plugin for refresh token example * chore: apply ruff * fix: update author in GitHub configuration and clean up Microsoft To Do schema * chore: bump version to 0.4.2b2 in pyproject.toml * feat: update Microsoft To Do plugin to handle OAuth token encoding and version bump * feat:remove inelegant example * chore: update dify_plugin version to 0.4.2 * chore: bump version to 0.4.2 in pyproject.toml --------- Co-authored-by: Yeuoly <admin@srmxy.cn>
517 lines
23 KiB
Python
517 lines
23 KiB
Python
import json
|
|
import logging
|
|
import time
|
|
from collections.abc import Generator
|
|
from copy import deepcopy
|
|
from typing import Any, cast
|
|
|
|
from pydantic import BaseModel
|
|
|
|
from dify_plugin.entities.agent import AgentInvokeMessage
|
|
from dify_plugin.entities.model import ModelFeature
|
|
from dify_plugin.entities.model.llm import (
|
|
LLMModelConfig,
|
|
LLMResult,
|
|
LLMResultChunk,
|
|
LLMUsage,
|
|
)
|
|
from dify_plugin.entities.model.message import (
|
|
AssistantPromptMessage,
|
|
PromptMessage,
|
|
PromptMessageContentType,
|
|
SystemPromptMessage,
|
|
ToolPromptMessage,
|
|
UserPromptMessage,
|
|
)
|
|
from dify_plugin.entities.provider_config import LogMetadata
|
|
from dify_plugin.entities.tool import ToolInvokeMessage, ToolProviderType
|
|
from dify_plugin.interfaces.agent import (
|
|
AgentModelConfig,
|
|
AgentStrategy,
|
|
ToolEntity,
|
|
ToolInvokeMeta,
|
|
)
|
|
|
|
|
|
class FunctionCallingParams(BaseModel):
|
|
query: str
|
|
instruction: str | None
|
|
model: AgentModelConfig
|
|
tools: list[ToolEntity] | None
|
|
maximum_iterations: int = 3
|
|
|
|
|
|
class FunctionCallingAgentStrategy(AgentStrategy):
|
|
query: str = ""
|
|
instruction: str | None = ""
|
|
|
|
@property
|
|
def _user_prompt_message(self) -> UserPromptMessage:
|
|
return UserPromptMessage(content=self.query)
|
|
|
|
@property
|
|
def _system_prompt_message(self) -> SystemPromptMessage:
|
|
return SystemPromptMessage(content=self.instruction)
|
|
|
|
def _invoke(self, parameters: dict[str, Any]) -> Generator[AgentInvokeMessage, None, None]:
|
|
"""
|
|
Run FunctionCall agent application
|
|
"""
|
|
fc_params = FunctionCallingParams(**parameters)
|
|
|
|
# init prompt messages
|
|
query = fc_params.query
|
|
self.query = query
|
|
self.instruction = fc_params.instruction
|
|
history_prompt_messages = fc_params.model.history_prompt_messages
|
|
history_prompt_messages.insert(0, self._system_prompt_message)
|
|
history_prompt_messages.append(self._user_prompt_message)
|
|
|
|
# convert tool messages
|
|
tools = fc_params.tools
|
|
tool_instances = {tool.identity.name: tool for tool in tools} if tools else {}
|
|
prompt_messages_tools = self._init_prompt_tools(tools)
|
|
|
|
# init model parameters
|
|
stream = (
|
|
ModelFeature.STREAM_TOOL_CALL in fc_params.model.entity.features
|
|
if fc_params.model.entity and fc_params.model.entity.features
|
|
else False
|
|
)
|
|
model = fc_params.model
|
|
stop = fc_params.model.completion_params.get("stop", []) if fc_params.model.completion_params else []
|
|
|
|
# init function calling state
|
|
iteration_step = 1
|
|
max_iteration_steps = fc_params.maximum_iterations
|
|
current_thoughts: list[PromptMessage] = []
|
|
function_call_state = True # continue to run until there is not any tool call
|
|
llm_usage: dict[str, LLMUsage | None] = {"usage": None}
|
|
final_answer = ""
|
|
|
|
while function_call_state and iteration_step <= max_iteration_steps:
|
|
# start a new round
|
|
function_call_state = False
|
|
round_started_at = time.perf_counter()
|
|
round_log = self.create_log_message(
|
|
label=f"ROUND {iteration_step}",
|
|
data={},
|
|
metadata={
|
|
LogMetadata.STARTED_AT: round_started_at,
|
|
},
|
|
status=ToolInvokeMessage.LogMessage.LogStatus.START,
|
|
)
|
|
yield round_log
|
|
|
|
# If max_iteration_steps=1, need to execute tool calls
|
|
if iteration_step == max_iteration_steps and max_iteration_steps > 1:
|
|
# the last iteration, remove all tools
|
|
prompt_messages_tools = []
|
|
|
|
# recalc llm max tokens
|
|
prompt_messages = self._organize_prompt_messages(
|
|
history_prompt_messages=history_prompt_messages,
|
|
current_thoughts=current_thoughts,
|
|
)
|
|
if model.entity and model.completion_params:
|
|
self.recalc_llm_max_tokens(model.entity, prompt_messages, model.completion_params)
|
|
# invoke model
|
|
model_started_at = time.perf_counter()
|
|
model_log = self.create_log_message(
|
|
label=f"{model.model} Thought",
|
|
data={},
|
|
metadata={
|
|
LogMetadata.STARTED_AT: model_started_at,
|
|
LogMetadata.PROVIDER: model.provider,
|
|
},
|
|
parent=round_log,
|
|
status=ToolInvokeMessage.LogMessage.LogStatus.START,
|
|
)
|
|
yield model_log
|
|
model_config = LLMModelConfig(**model.model_dump(mode="json"))
|
|
chunks: Generator[LLMResultChunk, None, None] | LLMResult = self.session.model.llm.invoke(
|
|
model_config=model_config,
|
|
prompt_messages=prompt_messages,
|
|
stop=stop,
|
|
stream=stream,
|
|
tools=prompt_messages_tools,
|
|
)
|
|
|
|
tool_calls: list[tuple[str, str, dict[str, Any]]] = []
|
|
|
|
# save full response
|
|
response = ""
|
|
|
|
# save tool call names and inputs
|
|
tool_call_names = ""
|
|
|
|
current_llm_usage = None
|
|
|
|
if isinstance(chunks, Generator):
|
|
for chunk in chunks:
|
|
# check if there is any tool call
|
|
if self.check_tool_calls(chunk):
|
|
function_call_state = True
|
|
tool_calls.extend(self.extract_tool_calls(chunk) or [])
|
|
tool_call_names = ";".join([tool_call[1] for tool_call in tool_calls])
|
|
|
|
if chunk.delta.message and chunk.delta.message.content:
|
|
if isinstance(chunk.delta.message.content, list):
|
|
for content in chunk.delta.message.content:
|
|
response += content.data
|
|
if not function_call_state or iteration_step == max_iteration_steps:
|
|
yield self.create_text_message(content.data)
|
|
else:
|
|
response += str(chunk.delta.message.content)
|
|
if not function_call_state or iteration_step == max_iteration_steps:
|
|
yield self.create_text_message(str(chunk.delta.message.content))
|
|
|
|
if chunk.delta.usage:
|
|
self.increase_usage(llm_usage, chunk.delta.usage)
|
|
current_llm_usage = chunk.delta.usage
|
|
|
|
else:
|
|
result = chunks
|
|
result = cast(LLMResult, result)
|
|
# check if there is any tool call
|
|
if self.check_blocking_tool_calls(result):
|
|
function_call_state = True
|
|
tool_calls.extend(self.extract_blocking_tool_calls(result) or [])
|
|
tool_call_names = ";".join([tool_call[1] for tool_call in tool_calls])
|
|
|
|
if result.usage:
|
|
self.increase_usage(llm_usage, result.usage)
|
|
current_llm_usage = result.usage
|
|
|
|
if result.message and result.message.content:
|
|
if isinstance(result.message.content, list):
|
|
for content in result.message.content:
|
|
response += content.data
|
|
else:
|
|
response += str(result.message.content)
|
|
|
|
if not result.message.content:
|
|
result.message.content = ""
|
|
if isinstance(result.message.content, str):
|
|
yield self.create_text_message(result.message.content)
|
|
elif isinstance(result.message.content, list):
|
|
for content in result.message.content:
|
|
yield self.create_text_message(content.data)
|
|
|
|
yield self.finish_log_message(
|
|
log=model_log,
|
|
data={
|
|
"output": response,
|
|
"tool_name": tool_call_names,
|
|
"tool_input": [{"name": tool_call[1], "args": tool_call[2]} for tool_call in tool_calls],
|
|
},
|
|
metadata={
|
|
LogMetadata.STARTED_AT: model_started_at,
|
|
LogMetadata.FINISHED_AT: time.perf_counter(),
|
|
LogMetadata.ELAPSED_TIME: time.perf_counter() - model_started_at,
|
|
LogMetadata.PROVIDER: model.provider,
|
|
LogMetadata.TOTAL_PRICE: current_llm_usage.total_price if current_llm_usage else 0,
|
|
LogMetadata.CURRENCY: current_llm_usage.currency if current_llm_usage else "",
|
|
LogMetadata.TOTAL_TOKENS: current_llm_usage.total_tokens if current_llm_usage else 0,
|
|
},
|
|
)
|
|
assistant_message = AssistantPromptMessage(content="", tool_calls=[])
|
|
if not tool_calls:
|
|
assistant_message.content = response
|
|
current_thoughts.append(assistant_message)
|
|
|
|
final_answer += response + "\n"
|
|
|
|
# call tools
|
|
tool_responses = []
|
|
for tool_call_id, tool_call_name, tool_call_args in tool_calls:
|
|
current_thoughts.append(
|
|
AssistantPromptMessage(
|
|
content="",
|
|
tool_calls=[
|
|
AssistantPromptMessage.ToolCall(
|
|
id=tool_call_id,
|
|
type="function",
|
|
function=AssistantPromptMessage.ToolCall.ToolCallFunction(
|
|
name=tool_call_name,
|
|
arguments=json.dumps(tool_call_args, ensure_ascii=False),
|
|
),
|
|
)
|
|
],
|
|
)
|
|
)
|
|
tool_instance = tool_instances[tool_call_name]
|
|
tool_call_started_at = time.perf_counter()
|
|
tool_call_log = self.create_log_message(
|
|
label=f"CALL {tool_call_name}",
|
|
data={},
|
|
metadata={
|
|
LogMetadata.STARTED_AT: time.perf_counter(),
|
|
LogMetadata.PROVIDER: tool_instance.identity.provider,
|
|
},
|
|
parent=round_log,
|
|
status=ToolInvokeMessage.LogMessage.LogStatus.START,
|
|
)
|
|
yield tool_call_log
|
|
if not tool_instance:
|
|
tool_response = {
|
|
"tool_call_id": tool_call_id,
|
|
"tool_call_name": tool_call_name,
|
|
"tool_response": f"there is not a tool named {tool_call_name}",
|
|
"meta": ToolInvokeMeta.error_instance(f"there is not a tool named {tool_call_name}").to_dict(),
|
|
}
|
|
else:
|
|
# invoke tool
|
|
try:
|
|
tool_invoke_responses = self.session.tool.invoke(
|
|
provider_type=ToolProviderType(tool_instance.provider_type),
|
|
provider=tool_instance.identity.provider,
|
|
tool_name=tool_instance.identity.name,
|
|
parameters={
|
|
**tool_instance.runtime_parameters,
|
|
**tool_call_args,
|
|
},
|
|
credential_id=tool_instance.credential_id,
|
|
)
|
|
tool_result = ""
|
|
for tool_invoke_response in tool_invoke_responses:
|
|
if tool_invoke_response.type == ToolInvokeMessage.MessageType.TEXT:
|
|
tool_result += cast(
|
|
ToolInvokeMessage.TextMessage,
|
|
tool_invoke_response.message,
|
|
).text
|
|
elif tool_invoke_response.type == ToolInvokeMessage.MessageType.LINK:
|
|
tool_result += (
|
|
"result link: "
|
|
+ cast(ToolInvokeMessage.TextMessage, tool_invoke_response.message).text
|
|
+ "."
|
|
+ " please tell user to check it."
|
|
)
|
|
elif tool_invoke_response.type in {
|
|
ToolInvokeMessage.MessageType.IMAGE_LINK,
|
|
ToolInvokeMessage.MessageType.IMAGE,
|
|
}:
|
|
# Extract the file path or URL from the message
|
|
if hasattr(tool_invoke_response.message, "text"):
|
|
file_info = cast(
|
|
ToolInvokeMessage.TextMessage,
|
|
tool_invoke_response.message,
|
|
).text
|
|
# Try to create a blob message with the file content
|
|
try:
|
|
# If it's a local file path, try to read it
|
|
if file_info.startswith("/files/"):
|
|
import os
|
|
|
|
if os.path.exists(file_info):
|
|
with open(file_info, "rb") as f:
|
|
file_content = f.read()
|
|
# Create a blob message with the file content
|
|
blob_response = self.create_blob_message(
|
|
blob=file_content,
|
|
meta={
|
|
"mime_type": "image/png",
|
|
"filename": os.path.basename(file_info),
|
|
},
|
|
)
|
|
yield blob_response
|
|
except Exception:
|
|
logging.exception("Failed to create blob message")
|
|
tool_result += (
|
|
"image has been created and sent to user already, "
|
|
+ "you do not need to create it, just tell the user to check it now."
|
|
)
|
|
# TODO: convert to agent invoke message
|
|
yield tool_invoke_response
|
|
elif tool_invoke_response.type == ToolInvokeMessage.MessageType.JSON:
|
|
text = json.dumps(
|
|
cast(
|
|
ToolInvokeMessage.JsonMessage,
|
|
tool_invoke_response.message,
|
|
).json_object,
|
|
ensure_ascii=False,
|
|
)
|
|
tool_result += f"tool response: {text}."
|
|
elif tool_invoke_response.type == ToolInvokeMessage.MessageType.BLOB:
|
|
tool_result += "Generated file ... "
|
|
# TODO: convert to agent invoke message
|
|
yield tool_invoke_response
|
|
else:
|
|
tool_result += f"tool response: {tool_invoke_response.message!r}."
|
|
except Exception as e:
|
|
tool_result = f"tool invoke error: {e!s}"
|
|
tool_response = {
|
|
"tool_call_id": tool_call_id,
|
|
"tool_call_name": tool_call_name,
|
|
"tool_call_input": {
|
|
**tool_instance.runtime_parameters,
|
|
**tool_call_args,
|
|
},
|
|
"tool_response": tool_result,
|
|
}
|
|
|
|
yield self.finish_log_message(
|
|
log=tool_call_log,
|
|
data={
|
|
"output": tool_response,
|
|
},
|
|
metadata={
|
|
LogMetadata.STARTED_AT: tool_call_started_at,
|
|
LogMetadata.PROVIDER: tool_instance.identity.provider,
|
|
LogMetadata.FINISHED_AT: time.perf_counter(),
|
|
LogMetadata.ELAPSED_TIME: time.perf_counter() - tool_call_started_at,
|
|
},
|
|
)
|
|
tool_responses.append(tool_response)
|
|
if tool_response["tool_response"] is not None:
|
|
current_thoughts.append(
|
|
ToolPromptMessage(
|
|
content=str(tool_response["tool_response"]),
|
|
tool_call_id=tool_call_id,
|
|
name=tool_call_name,
|
|
)
|
|
)
|
|
|
|
# update prompt tool
|
|
for prompt_tool in prompt_messages_tools:
|
|
self.update_prompt_message_tool(tool_instances[prompt_tool.name], prompt_tool)
|
|
yield self.finish_log_message(
|
|
log=round_log,
|
|
data={
|
|
"output": {
|
|
"llm_response": response,
|
|
"tool_responses": tool_responses,
|
|
},
|
|
},
|
|
metadata={
|
|
LogMetadata.STARTED_AT: round_started_at,
|
|
LogMetadata.FINISHED_AT: time.perf_counter(),
|
|
LogMetadata.ELAPSED_TIME: time.perf_counter() - round_started_at,
|
|
LogMetadata.TOTAL_PRICE: current_llm_usage.total_price if current_llm_usage else 0,
|
|
LogMetadata.CURRENCY: current_llm_usage.currency if current_llm_usage else "",
|
|
LogMetadata.TOTAL_TOKENS: current_llm_usage.total_tokens if current_llm_usage else 0,
|
|
},
|
|
)
|
|
# If max_iteration_steps=1, need to return tool responses
|
|
if tool_responses and max_iteration_steps == 1:
|
|
for resp in tool_responses:
|
|
yield self.create_text_message(str(resp["tool_response"]))
|
|
iteration_step += 1
|
|
yield self.create_json_message(
|
|
{
|
|
"execution_metadata": {
|
|
LogMetadata.TOTAL_PRICE: llm_usage["usage"].total_price if llm_usage["usage"] is not None else 0,
|
|
LogMetadata.CURRENCY: llm_usage["usage"].currency if llm_usage["usage"] is not None else "",
|
|
LogMetadata.TOTAL_TOKENS: llm_usage["usage"].total_tokens if llm_usage["usage"] is not None else 0,
|
|
}
|
|
}
|
|
)
|
|
|
|
def check_tool_calls(self, llm_result_chunk: LLMResultChunk) -> bool:
|
|
"""
|
|
Check if there is any tool call in llm result chunk
|
|
"""
|
|
return bool(llm_result_chunk.delta.message.tool_calls)
|
|
|
|
def check_blocking_tool_calls(self, llm_result: LLMResult) -> bool:
|
|
"""
|
|
Check if there is any blocking tool call in llm result
|
|
"""
|
|
return bool(llm_result.message.tool_calls)
|
|
|
|
def extract_tool_calls(self, llm_result_chunk: LLMResultChunk) -> list[tuple[str, str, dict[str, Any]]]:
|
|
"""
|
|
Extract tool calls from llm result chunk
|
|
|
|
Returns:
|
|
List[Tuple[str, str, Dict[str, Any]]]: [(tool_call_id, tool_call_name, tool_call_args)]
|
|
"""
|
|
tool_calls = []
|
|
for prompt_message in llm_result_chunk.delta.message.tool_calls:
|
|
args = {}
|
|
if prompt_message.function.arguments != "":
|
|
args = json.loads(prompt_message.function.arguments)
|
|
|
|
tool_calls.append(
|
|
(
|
|
prompt_message.id,
|
|
prompt_message.function.name,
|
|
args,
|
|
)
|
|
)
|
|
|
|
return tool_calls
|
|
|
|
def extract_blocking_tool_calls(self, llm_result: LLMResult) -> list[tuple[str, str, dict[str, Any]]]:
|
|
"""
|
|
Extract blocking tool calls from llm result
|
|
|
|
Returns:
|
|
List[Tuple[str, str, Dict[str, Any]]]: [(tool_call_id, tool_call_name, tool_call_args)]
|
|
"""
|
|
tool_calls = []
|
|
for prompt_message in llm_result.message.tool_calls:
|
|
args = {}
|
|
if prompt_message.function.arguments != "":
|
|
args = json.loads(prompt_message.function.arguments)
|
|
|
|
tool_calls.append(
|
|
(
|
|
prompt_message.id,
|
|
prompt_message.function.name,
|
|
args,
|
|
)
|
|
)
|
|
|
|
return tool_calls
|
|
|
|
def _init_system_message(self, prompt_template: str, prompt_messages: list[PromptMessage]) -> list[PromptMessage]:
|
|
"""
|
|
Initialize system message
|
|
"""
|
|
if not prompt_messages and prompt_template:
|
|
return [
|
|
SystemPromptMessage(content=prompt_template),
|
|
]
|
|
|
|
if prompt_messages and not isinstance(prompt_messages[0], SystemPromptMessage) and prompt_template:
|
|
prompt_messages.insert(0, SystemPromptMessage(content=prompt_template))
|
|
|
|
return prompt_messages or []
|
|
|
|
def _clear_user_prompt_image_messages(self, prompt_messages: list[PromptMessage]) -> list[PromptMessage]:
|
|
"""
|
|
As for now, gpt supports both fc and vision at the first iteration.
|
|
We need to remove the image messages from the prompt messages at the first iteration.
|
|
"""
|
|
prompt_messages = deepcopy(prompt_messages)
|
|
|
|
for prompt_message in prompt_messages:
|
|
if isinstance(prompt_message, UserPromptMessage) and isinstance(prompt_message.content, list):
|
|
prompt_message.content = "\n".join(
|
|
[
|
|
content.data
|
|
if content.type == PromptMessageContentType.TEXT
|
|
else "[image]"
|
|
if content.type == PromptMessageContentType.IMAGE
|
|
else "[file]"
|
|
for content in prompt_message.content
|
|
]
|
|
)
|
|
|
|
return prompt_messages
|
|
|
|
def _organize_prompt_messages(
|
|
self,
|
|
current_thoughts: list[PromptMessage],
|
|
history_prompt_messages: list[PromptMessage],
|
|
) -> list[PromptMessage]:
|
|
prompt_messages = [
|
|
*history_prompt_messages,
|
|
*current_thoughts,
|
|
]
|
|
if len(current_thoughts) != 0:
|
|
# clear messages after the first iteration
|
|
prompt_messages = self._clear_user_prompt_image_messages(prompt_messages)
|
|
return prompt_messages
|