agno-agi / agno

Multi-agent framework, runtime and control plane. Built for speed, privacy, and scale.

Home Page:https://docs.agno.com

Repository from Github https://github.comagno-agi/agnoRepository from Github https://github.comagno-agi/agno

[Feature Request] Added a new ToolCallArgsDeltaEvent to enable real-time streaming of tool call arguments, enhancing the user experience.

donghuang opened this issue · comments

commented

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.