Files
Maries c6f83a63e1 feat[0.4.2]: Tool OAuth (#179)
* 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>
2025-07-23 13:49:01 +08:00

469 lines
20 KiB
Python

import json
import time
from collections.abc import Generator, Mapping
from typing import Any, cast
import pydantic
from pydantic import BaseModel, Field
from dify_plugin.entities.agent import AgentInvokeMessage
from dify_plugin.entities.model.llm import LLMModelConfig, LLMUsage
from dify_plugin.entities.model.message import (
AssistantPromptMessage,
PromptMessage,
SystemPromptMessage,
UserPromptMessage,
)
from dify_plugin.entities.provider_config import LogMetadata
from dify_plugin.entities.tool import (
ToolInvokeMessage,
ToolParameter,
ToolProviderType,
)
from dify_plugin.interfaces.agent import (
AgentModelConfig,
AgentScratchpadUnit,
AgentStrategy,
ToolEntity,
)
from examples.agent.output_parser.cot_output_parser import CotAgentOutputParser
from examples.agent.prompt.template import REACT_PROMPT_TEMPLATES
ignore_observation_providers = ["wenxin"]
class ReActParams(BaseModel):
query: str
instruction: str
model: AgentModelConfig
tools: list[ToolEntity] | None
maximum_iterations: int = 3
class AgentPromptEntity(BaseModel):
"""
Agent Prompt Entity.
"""
first_prompt: str
next_iteration: str
class ReActAgentStrategy(AgentStrategy):
query: str = ""
instruction: str = ""
history_prompt_messages: list[PromptMessage] = Field(default_factory=list)
prompt_messages_tools: list[ToolEntity] = Field(default_factory=list)
@property
def _user_prompt_message(self) -> UserPromptMessage:
return UserPromptMessage(content=self.query)
@property
def _system_prompt_message(self) -> SystemPromptMessage:
prompt_entity = AgentPromptEntity(
first_prompt=REACT_PROMPT_TEMPLATES["english"]["chat"]["prompt"],
next_iteration=REACT_PROMPT_TEMPLATES["english"]["chat"]["agent_scratchpad"],
)
if not prompt_entity:
raise ValueError("Agent prompt configuration is not set")
first_prompt = prompt_entity.first_prompt
system_prompt = (
first_prompt.replace("{{instruction}}", self.instruction)
.replace(
"{{tools}}",
json.dumps([tool.model_dump(mode="json") for tool in self._prompt_messages_tools]),
)
.replace(
"{{tool_names}}",
", ".join([tool.name for tool in self._prompt_messages_tools]),
)
)
return SystemPromptMessage(content=system_prompt)
def _invoke(self, parameters: dict[str, Any]) -> Generator[AgentInvokeMessage]:
try:
react_params = ReActParams(**parameters)
except pydantic.ValidationError as e:
raise ValueError(f"Invalid parameters: {e!s}") from e
# Init parameters
self.query = react_params.query
self.instruction = react_params.instruction
agent_scratchpad: list[AgentScratchpadUnit] = []
iteration_step = 1
max_iteration_steps = react_params.maximum_iterations
run_agent_state = True
llm_usage: dict[str, LLMUsage | None] = {"usage": None}
final_answer = ""
prompt_messages: list[PromptMessage] = []
# Init model
model = react_params.model
stop = react_params.model.completion_params.get("stop", []) if react_params.model.completion_params else []
if "Observation" not in stop and model.provider not in ignore_observation_providers:
stop.append("Observation")
# Init prompts
self.history_prompt_messages = model.history_prompt_messages
# convert tools into ModelRuntime Tool format
tools = react_params.tools
tool_instances = {tool.identity.name: tool for tool in tools} if tools else {}
react_params.model.completion_params = react_params.model.completion_params or {}
prompt_messages_tools = self._init_prompt_tools(tools)
self._prompt_messages_tools = prompt_messages_tools
while run_agent_state and iteration_step <= max_iteration_steps:
# continue to run until there is not any tool call
run_agent_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 iteration_step == max_iteration_steps:
# the last iteration, remove all tools
self._prompt_messages_tools = []
message_file_ids: list[str] = []
# recalc llm max tokens
prompt_messages = self._organize_prompt_messages(agent_scratchpad, self.query)
if model.entity and model.completion_params:
self.recalc_llm_max_tokens(model.entity, prompt_messages, model.completion_params)
# invoke model
chunks = self.session.model.llm.invoke(
model_config=LLMModelConfig(**model.model_dump(mode="json")),
prompt_messages=prompt_messages,
stream=True,
stop=stop,
)
usage_dict: dict[str, LLMUsage | None] = {"usage": None}
react_chunks = CotAgentOutputParser.handle_react_stream_output(chunks, usage_dict)
scratchpad = AgentScratchpadUnit(
agent_response="",
thought="",
action_str="",
observation="",
action=None,
)
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
for chunk in react_chunks:
if isinstance(chunk, AgentScratchpadUnit.Action):
action = chunk
# detect action
assert scratchpad.agent_response is not None
scratchpad.agent_response += json.dumps(chunk.model_dump())
scratchpad.action_str = json.dumps(chunk.model_dump())
scratchpad.action = action
else:
scratchpad.agent_response = scratchpad.agent_response or ""
scratchpad.thought = scratchpad.thought or ""
scratchpad.agent_response += chunk
scratchpad.thought += chunk
scratchpad.thought = (
scratchpad.thought.strip() if scratchpad.thought else "I am thinking about how to help you"
)
agent_scratchpad.append(scratchpad)
# get llm usage
if "usage" in usage_dict:
if usage_dict["usage"] is not None:
self.increase_usage(llm_usage, usage_dict["usage"])
else:
usage_dict["usage"] = LLMUsage.empty_usage()
action_dict = scratchpad.action.to_dict() if scratchpad.action else {"action": scratchpad.agent_response}
yield self.finish_log_message(
log=model_log,
data={"thought": scratchpad.thought, **action_dict},
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: usage_dict["usage"].total_price if usage_dict["usage"] else 0,
LogMetadata.CURRENCY: usage_dict["usage"].currency if usage_dict["usage"] else "",
LogMetadata.TOTAL_TOKENS: usage_dict["usage"].total_tokens if usage_dict["usage"] else 0,
},
)
if not scratchpad.action:
final_answer = scratchpad.thought
else:
if scratchpad.action.action_name.lower() == "final answer":
# action is final answer, return final answer directly
try:
if isinstance(scratchpad.action.action_input, dict):
final_answer = json.dumps(scratchpad.action.action_input)
elif isinstance(scratchpad.action.action_input, str):
final_answer = scratchpad.action.action_input
else:
final_answer = f"{scratchpad.action.action_input}"
except json.JSONDecodeError:
final_answer = f"{scratchpad.action.action_input}"
else:
run_agent_state = True
# action is tool call, invoke tool
tool_call_started_at = time.perf_counter()
tool_name = scratchpad.action.action_name
tool_call_log = self.create_log_message(
label=f"CALL {tool_name}",
data={},
metadata={
LogMetadata.STARTED_AT: time.perf_counter(),
LogMetadata.PROVIDER: tool_instances[tool_name].identity.provider
if tool_instances.get(tool_name)
else "",
},
parent=round_log,
status=ToolInvokeMessage.LogMessage.LogStatus.START,
)
yield tool_call_log
tool_invoke_response, tool_invoke_parameters, additional_messages = self._handle_invoke_action(
action=scratchpad.action,
tool_instances=tool_instances,
message_file_ids=message_file_ids,
)
scratchpad.observation = tool_invoke_response
scratchpad.agent_response = tool_invoke_response
# TODO: convert to agent invoke message
yield from additional_messages
yield self.finish_log_message(
log=tool_call_log,
data={
"tool_name": tool_name,
"tool_call_args": tool_invoke_parameters,
"output": tool_invoke_response,
},
metadata={
LogMetadata.STARTED_AT: tool_call_started_at,
LogMetadata.PROVIDER: tool_instances[tool_name].identity.provider
if tool_instances.get(tool_name)
else "",
LogMetadata.FINISHED_AT: time.perf_counter(),
LogMetadata.ELAPSED_TIME: time.perf_counter() - tool_call_started_at,
},
)
# update prompt tool message
for prompt_tool in self._prompt_messages_tools:
self.update_prompt_message_tool(tool_instances[prompt_tool.name], prompt_tool)
yield self.finish_log_message(
log=round_log,
data={
"action_name": scratchpad.action.action_name if scratchpad.action else "",
"action_input": scratchpad.action.action_input if scratchpad.action else "",
"thought": scratchpad.thought,
"observation": scratchpad.observation,
},
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: usage_dict["usage"].total_price if usage_dict["usage"] else 0,
LogMetadata.CURRENCY: usage_dict["usage"].currency if usage_dict["usage"] else "",
LogMetadata.TOTAL_TOKENS: usage_dict["usage"].total_tokens if usage_dict["usage"] else 0,
},
)
iteration_step += 1
yield self.create_text_message(final_answer)
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 _organize_user_query(self, query, prompt_messages: list[PromptMessage]) -> list[PromptMessage]:
"""
Organize user query
"""
prompt_messages.append(UserPromptMessage(content=query))
return prompt_messages
def _organize_prompt_messages(self, agent_scratchpad: list, query: str) -> list[PromptMessage]:
"""
Organize
"""
# organize system prompt
system_message = self._system_prompt_message
# organize current assistant messages
agent_scratchpad = agent_scratchpad
if not agent_scratchpad:
assistant_messages = []
else:
assistant_message = AssistantPromptMessage(content="")
for unit in agent_scratchpad:
if unit.is_final():
assert isinstance(assistant_message.content, str)
assistant_message.content += f"Final Answer: {unit.agent_response}"
else:
assert isinstance(assistant_message.content, str)
assistant_message.content += f"Thought: {unit.thought}\n\n"
if unit.action_str:
assistant_message.content += f"Action: {unit.action_str}\n\n"
if unit.observation:
assistant_message.content += f"Observation: {unit.observation}\n\n"
assistant_messages = [assistant_message]
# query messages
query_messages = self._organize_user_query(query, [])
if assistant_messages:
# organize historic prompt messages
historic_messages = self.history_prompt_messages
messages = [
system_message,
*historic_messages,
*query_messages,
*assistant_messages,
UserPromptMessage(content="continue"),
]
else:
# organize historic prompt messages
historic_messages = self.history_prompt_messages
messages = [system_message, *historic_messages, *query_messages]
# join all messages
return messages
def _handle_invoke_action(
self,
action: AgentScratchpadUnit.Action,
tool_instances: Mapping[str, ToolEntity],
message_file_ids: list[str],
) -> tuple[str, dict[str, Any] | str, list[ToolInvokeMessage]]:
"""
handle invoke action
:param action: action
:param tool_instances: tool instances
:param message_file_ids: message file ids
:param trace_manager: trace manager
:return: observation, meta
"""
# action is tool call, invoke tool
tool_call_name = action.action_name
tool_call_args = action.action_input
tool_instance = tool_instances.get(tool_call_name)
if not tool_instance:
answer = f"there is not a tool named {tool_call_name}"
return answer, tool_call_args, []
if isinstance(tool_call_args, str):
try:
tool_call_args = json.loads(tool_call_args)
except json.JSONDecodeError as e:
params = [
param.name
for param in tool_instance.parameters
if param.form == ToolParameter.ToolParameterForm.LLM
]
if len(params) > 1:
raise ValueError("tool call args is not a valid json string") from e
tool_call_args = {params[0]: tool_call_args} if len(params) == 1 else {}
tool_call_args = cast(dict[str, Any], tool_call_args)
tool_invoke_parameters = {**tool_instance.runtime_parameters, **tool_call_args}
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_invoke_parameters,
credential_id=tool_instance.credential_id,
)
result = ""
additional_messages = [] # Collect messages that need to be yielded
for response in tool_invoke_responses:
if response.type == ToolInvokeMessage.MessageType.TEXT:
result += cast(ToolInvokeMessage.TextMessage, response.message).text
elif response.type == ToolInvokeMessage.MessageType.LINK:
result += (
f"result link: {cast(ToolInvokeMessage.TextMessage, response.message).text}."
+ " please tell user to check it."
)
elif response.type in {
ToolInvokeMessage.MessageType.IMAGE_LINK,
ToolInvokeMessage.MessageType.IMAGE,
}:
# Pass through the original IMAGE_LINK response for upper layer handling
additional_messages.append(response)
# Include the actual file path information for the LLM
image_link_text = cast(ToolInvokeMessage.TextMessage, response.message).text
result += (
f"Image has been successfully generated and saved to: {image_link_text}. "
+ "The image file is now available for download. "
+ "Please inform the user that the image has been created successfully."
)
elif response.type == ToolInvokeMessage.MessageType.JSON:
text = json.dumps(
cast(ToolInvokeMessage.JsonMessage, response.message).json_object,
ensure_ascii=False,
)
result += f"tool response: {text}."
elif response.type == ToolInvokeMessage.MessageType.BLOB:
result += "Generated file with ... "
additional_messages.append(response)
else:
result += f"tool response: {response.message!r}."
except Exception as e:
result = f"tool invoke error: {e!s}"
additional_messages = []
return result, tool_invoke_parameters, additional_messages
def _convert_dict_to_action(self, action: dict) -> AgentScratchpadUnit.Action:
"""
convert dict to action
"""
return AgentScratchpadUnit.Action(action_name=action["action"], action_input=action["action_input"])
def _format_assistant_message(self, agent_scratchpad: list[AgentScratchpadUnit]) -> str:
"""
format assistant message
"""
message = ""
for scratchpad in agent_scratchpad:
if scratchpad.is_final():
message += f"Final Answer: {scratchpad.agent_response}"
else:
message += f"Thought: {scratchpad.thought}\n\n"
if scratchpad.action_str:
message += f"Action: {scratchpad.action_str}\n\n"
if scratchpad.observation:
message += f"Observation: {scratchpad.observation}\n\n"
return message