[Feature Request] Added a new ToolCallArgsDeltaEvent to enable real-time streaming of tool call arguments, enhancing the user experience.
donghuang opened this issue · comments
Problem Description
Added a new ToolCallArgsDeltaEvent to enable real-time streaming of tool call arguments, enhancing the user experience.
Proposed Solution
DashScope as an example:
Added a new ToolCallArgsDeltaEvent to enable real-time streaming of tool call arguments, enhancing the user experience
1、agno/libs/agno/agno/run/agent.py
`@dataclass
class CustomEvent(BaseAgentRunEvent):
event: str = RunEvent.custom_event.value
def __init__(self, **kwargs):
# Store arbitrary attributes directly on the instance
for key, value in kwargs.items():
setattr(self, key, value)
@DataClass
class ToolCallArgsDeltaEvent(BaseAgentRunEvent):
event: str = RunEvent.tool_call_args_delta.value
tool_call_index: Optional[int] = None
tool_call_id: Optional[str] = None
tool_call_name: Optional[str] = None
arguments_delta: str = ""
RunOutputEvent = Union[
RunStartedEvent,
RunContentEvent,
IntermediateRunContentEvent,
RunContentCompletedEvent,
RunCompletedEvent,
RunErrorEvent,
RunCancelledEvent,
RunPausedEvent,
RunContinuedEvent,
PreHookStartedEvent,
PreHookCompletedEvent,
PostHookStartedEvent,
PostHookCompletedEvent,
ReasoningStartedEvent,
ReasoningStepEvent,
ReasoningCompletedEvent,
MemoryUpdateStartedEvent,
MemoryUpdateCompletedEvent,
SessionSummaryStartedEvent,
SessionSummaryCompletedEvent,
ToolCallStartedEvent,
ToolCallCompletedEvent,
ParserModelResponseStartedEvent,
ParserModelResponseCompletedEvent,
OutputModelResponseStartedEvent,
OutputModelResponseCompletedEvent,
CustomEvent,
ToolCallArgsDeltaEvent,
]
##########
class RunEvent(str, Enum):
"""Events that can be sent by the run() functions"""
run_started = "RunStarted"
run_content = "RunContent"
run_content_completed = "RunContentCompleted"
run_intermediate_content = "RunIntermediateContent"
run_completed = "RunCompleted"
run_error = "RunError"
run_cancelled = "RunCancelled"
run_paused = "RunPaused"
run_continued = "RunContinued"
pre_hook_started = "PreHookStarted"
pre_hook_completed = "PreHookCompleted"
post_hook_started = "PostHookStarted"
post_hook_completed = "PostHookCompleted"
tool_call_started = "ToolCallStarted"
tool_call_completed = "ToolCallCompleted"
reasoning_started = "ReasoningStarted"
reasoning_step = "ReasoningStep"
reasoning_completed = "ReasoningCompleted"
memory_update_started = "MemoryUpdateStarted"
memory_update_completed = "MemoryUpdateCompleted"
session_summary_started = "SessionSummaryStarted"
session_summary_completed = "SessionSummaryCompleted"
parser_model_response_started = "ParserModelResponseStarted"
parser_model_response_completed = "ParserModelResponseCompleted"
output_model_response_started = "OutputModelResponseStarted"
output_model_response_completed = "OutputModelResponseCompleted"
custom_event = "CustomEvent"
tool_call_args_delta = "ToolCallArgsDelta"
`
2、agno/libs/agno/agno/utils/events.py
from agno.run.agent import (
MemoryUpdateCompletedEvent,
MemoryUpdateStartedEvent,
OutputModelResponseCompletedEvent,
OutputModelResponseStartedEvent,
ParserModelResponseCompletedEvent,
ParserModelResponseStartedEvent,
PostHookCompletedEvent,
PostHookStartedEvent,
PreHookCompletedEvent,
PreHookStartedEvent,
ReasoningCompletedEvent,
ReasoningStartedEvent,
ReasoningStepEvent,
RunCancelledEvent,
RunCompletedEvent,
RunContentCompletedEvent,
RunContentEvent,
RunContinuedEvent,
RunErrorEvent,
RunEvent,
RunInput,
RunOutput,
RunOutputEvent,
RunPausedEvent,
RunStartedEvent,
SessionSummaryCompletedEvent,
SessionSummaryStartedEvent,
ToolCallCompletedEvent,
ToolCallStartedEvent,
ToolCallArgsDeltaEvent,
)
3、agno/libs/agno/agno/models/response.py
@DataClass
class ModelResponse:
"""Response from the model provider"""
role: Optional[str] = None
content: Optional[Any] = None
parsed: Optional[Any] = None
audio: Optional[Audio] = None
# Unified media fields for LLM-generated and tool-generated media artifacts
images: Optional[List[Image]] = None
videos: Optional[List[Video]] = None
audios: Optional[List[Audio]] = None
files: Optional[List[File]] = None
# Model tool calls
tool_calls: List[Dict[str, Any]] = field(default_factory=list)
# Actual tool executions
tool_executions: Optional[List[ToolExecution]] = field(default_factory=list)
event: str = ModelResponseEvent.assistant_response.value
provider_data: Optional[Dict[str, Any]] = None
redacted_reasoning_content: Optional[str] = None
reasoning_content: Optional[str] = None
citations: Optional[Citations] = None
response_usage: Optional[Metrics] = None
created_at: int = int(time())
extra: Optional[Dict[str, Any]] = None
updated_session_state: Optional[Dict[str, Any]] = None
tool_call_deltas: Optional[List[Dict[str, Any]]] = None
4、agno/libs/agno/agno/agent/agent.py
# Handle citations (one chunk)
if model_response_event.citations is not None:
run_response.citations = model_response_event.citations
if model_response_event.tool_call_deltas is not None:
run_response.tool_call_deltas = model_response_event.tool_call_deltas
from agno.run.agent import ToolCallArgsDeltaEvent
tool_call_delta = model_response_event.tool_call_deltas[0] if model_response_event.tool_call_deltas else {}
event = ToolCallArgsDeltaEvent(
session_id=run_response.session_id,
agent_id=run_response.agent_id, # type: ignore
agent_name=run_response.agent_name, # type: ignore
run_id=run_response.run_id,
tool_call_index=tool_call_delta.get("index"),
tool_call_id=tool_call_delta.get("id"),
tool_call_name=tool_call_delta.get("name"),
arguments_delta=tool_call_delta.get("arguments_delta", "")
)
yield handle_event(event, run_response)
5agno/libs/agno/agno/models/dashscope/dashscope.py
from dataclasses import dataclass, field
from os import getenv
from agno.exceptions import ModelProviderError
from agno.models.openai.like import OpenAILike
from typing import Iterator, Any, Dict, List, Optional, Type, Union
from agno.models.response import ModelResponse
from agno.models.message import Message
from agno.run.agent import RunOutput
from pydantic import BaseModel
from agno.utils.log import log_debug, log_info
import json
@DataClass
class DashScope(OpenAILike):
"""
A class for interacting with Qwen models via DashScope API.
Attributes:
id (str): The model id. Defaults to "qwen-plus".
name (str): The model name. Defaults to "Qwen".
provider (str): The provider name. Defaults to "Qwen".
api_key (Optional[str]): The DashScope API key.
base_url (str): The base URL. Defaults to "https://dashscope-intl.aliyuncs.com/compatible-mode/v1".
enable_thinking (bool): Enable thinking process (DashScope native parameter). Defaults to False.
include_thoughts (Optional[bool]): Include thinking process in response (alternative parameter). Defaults to None.
"""
id: str = "qwen-plus"
name: str = "Qwen"
provider: str = "Dashscope"
api_key: Optional[str] = getenv("DASHSCOPE_API_KEY") or getenv("QWEN_API_KEY")
base_url: str = "https://dashscope-intl.aliyuncs.com/compatible-mode/v1"
# Thinking parameters
enable_thinking: bool = False
include_thoughts: Optional[bool] = None
thinking_budget: Optional[int] = None
# DashScope supports structured outputs
supports_native_structured_outputs: bool = True
supports_json_schema_outputs: bool = True
_tool_call_metadata: Dict[int, Dict[str, Optional[str]]] = field(
default_factory=dict, init=False, repr=False
)
def _get_client_params(self) -> Dict[str, Any]:
if not self.api_key:
self.api_key = getenv("DASHSCOPE_API_KEY")
if not self.api_key:
raise ModelProviderError(
message="DASHSCOPE_API_KEY not set. Please set the DASHSCOPE_API_KEY environment variable.",
model_name=self.name,
model_id=self.id,
)
# Define base client params
base_params = {
"api_key": self.api_key,
"organization": self.organization,
"base_url": self.base_url,
"timeout": self.timeout,
"max_retries": self.max_retries,
"default_headers": self.default_headers,
"default_query": self.default_query,
}
# Create client_params dict with non-None values
client_params = {k: v for k, v in base_params.items() if v is not None}
# Add additional client params if provided
if self.client_params:
client_params.update(self.client_params)
return client_params
def get_request_params(
self,
response_format: Optional[Union[Dict, Type[BaseModel]]] = None,
tools: Optional[List[Dict[str, Any]]] = None,
tool_choice: Optional[Union[str, Dict[str, Any]]] = None,
**kwargs: Any,
) -> Dict[str, Any]:
params = super().get_request_params(response_format=response_format, tools=tools, tool_choice=tool_choice)
if self.include_thoughts is not None:
self.enable_thinking = self.include_thoughts
if self.enable_thinking is not None:
params["extra_body"] = {
"enable_thinking": self.enable_thinking,
}
if self.thinking_budget is not None:
params["extra_body"]["thinking_budget"] = self.thinking_budget
return params
def invoke_stream(
self,
messages: List[Message],
assistant_message: Message,
response_format: Optional[Union[Dict, Type[BaseModel]]] = None,
tools: Optional[List[Dict[str, Any]]] = None,
tool_choice: Optional[Union[str, Dict[str, Any]]] = None,
run_response: Optional[RunOutput] = None,
) -> Iterator[ModelResponse]:
self._tool_call_metadata = {}
for model_response in super().invoke_stream(
messages=messages,
assistant_message=assistant_message,
response_format=response_format,
tools=tools,
tool_choice=tool_choice,
run_response=run_response
):
if hasattr(model_response, 'tool_call_deltas') and model_response.tool_call_deltas:
for delta in model_response.tool_call_deltas:
delta_response = ModelResponse(
event="tool_call_args_delta",
content=delta.get("arguments_delta", "")
)
delta_response.extra = {
"tool_call_index": delta.get("index"),
"tool_call_id": delta.get("id"),
"tool_call_name": delta.get("name"),
"arguments_delta": delta.get("arguments_delta", "")
}
yield delta_response
yield model_response
self._tool_call_metadata = {}
def _parse_provider_response_delta(self, response_delta: Any) -> ModelResponse:
model_response = super()._parse_provider_response_delta(response_delta)
if response_delta.choices and len(response_delta.choices) > 0:
delta = response_delta.choices[0].delta
if hasattr(delta, 'tool_calls') and delta.tool_calls:
if not hasattr(model_response, 'tool_call_deltas') or model_response.tool_call_deltas is None:
model_response.tool_call_deltas = []
for tool_call in delta.tool_calls:
index = getattr(tool_call, 'index', 0)
tool_call_id = getattr(tool_call, 'id', None)
if index not in self._tool_call_metadata:
self._tool_call_metadata[index] = {'id': None, 'name': None}
if tool_call_id:
self._tool_call_metadata[index]['id'] = tool_call_id
if hasattr(tool_call, 'function') and tool_call.function:
function_name = getattr(tool_call.function, 'name', None)
if function_name:
self._tool_call_metadata[index]['name'] = function_name
arguments = getattr(tool_call.function, 'arguments', None)
if arguments:
model_response.tool_call_deltas.append({
"index": index,
"id": self._tool_call_metadata[index]['id'],
"name": self._tool_call_metadata[index]['name'],
"arguments_delta": arguments
})
#log_info(model_response)
return model_response
def _emit_tool_args_delta(self, delta: Dict[str, Any], run_response: Optional[RunOutput]):
if run_response and run_response.event_handler:
from agno.utils.events import ToolCallArgsDeltaEvent
event = ToolCallArgsDeltaEvent(
tool_call_index=delta.get("index"),
tool_call_id=delta.get("id"),
tool_call_name=delta.get("name"),
arguments_delta=delta.get("arguments_delta", "")
)
run_response.event_handler.on_event(event)
Alternatives Considered
No response
Additional Context
No response
Would you like to work on this?
- Yes, I’d love to work on it!
- I’m open to collaborating but need guidance.
- No, I’m just sharing the idea.